mod art;
mod browse;
mod devices;
mod view;
use std::io::{self, Stdout};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph};
use ratatui_image::StatefulImage;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::StatefulProtocol;
use rspotify::AuthCodePkceSpotify;
use rspotify::model::{
CurrentPlaybackContext, FullTrack, LibraryId, Offset, PlayableId, PlayableItem, SearchResult,
SearchType, TrackId,
};
use rspotify::prelude::*;
use crate::auth;
use crate::config::Config;
use crate::format::join_artists;
use view::NowPlaying;
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const TICK: Duration = Duration::from_millis(200);
const VOL_STEP: i16 = 5;
const SEEK_STEP_MS: i64 = 5_000;
const MAX_POLL_FAILURES: u32 = 3;
const SEARCH_LIMIT: u32 = 10;
const STATUS_TTL: Duration = Duration::from_secs(4);
type Term = Terminal<CrosstermBackend<Stdout>>;
enum Mode {
Normal,
Search(SearchState),
Browse(browse::BrowseState),
Devices(devices::DevicePickerState),
Help,
}
#[derive(Clone, Copy)]
enum ModeKind {
Normal,
Search,
Browse,
Devices,
Help,
}
impl Mode {
fn kind(&self) -> ModeKind {
match self {
Mode::Normal => ModeKind::Normal,
Mode::Search(_) => ModeKind::Search,
Mode::Browse(_) => ModeKind::Browse,
Mode::Devices(_) => ModeKind::Devices,
Mode::Help => ModeKind::Help,
}
}
}
struct SearchState {
query: String,
phase: SearchPhase,
results: Vec<TrackHit>,
selected: usize,
message: Option<String>,
}
impl SearchState {
fn new() -> Self {
Self {
query: String::new(),
phase: SearchPhase::Input,
results: Vec::new(),
selected: 0,
message: None,
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum SearchPhase {
Input,
Results,
}
struct TrackHit {
name: String,
artists: String,
uri: String,
}
enum SearchAction {
None,
Close,
Submit(String),
Play {
uris: Vec<String>,
selected: usize,
},
BackToInput,
}
struct App {
client: AuthCodePkceSpotify,
now: Option<NowPlaying>,
status: String,
last_poll: Option<Instant>,
poll_failures: u32,
mode: Mode,
browse_cache: browse::BrowseCache,
saved: Option<bool>,
saved_checked: bool,
http: reqwest::Client,
picker: Picker,
art: Option<StatefulProtocol>,
art_url: Option<String>,
}
pub async fn run(cfg: &Config) -> Result<()> {
let client = auth::authed_client(cfg)
.await
.context("cannot start the TUI")?;
let picker = Picker::from_query_stdio().unwrap_or_else(|_| Picker::halfblocks());
install_panic_hook();
let mut terminal = setup_terminal().context("failed to initialize the terminal")?;
let result = run_loop(&mut terminal, client, picker).await;
let restored = restore_terminal(&mut terminal);
match (result, restored) {
(Ok(()), restored) => restored,
(Err(e), Ok(())) => Err(e),
(Err(e), Err(re)) => {
Err(e.context(format!("and restoring the terminal also failed: {re}")))
}
}
}
async fn run_loop(terminal: &mut Term, client: AuthCodePkceSpotify, picker: Picker) -> Result<()> {
let mut app = App {
client,
now: None,
status: "Starting…".to_string(),
last_poll: None,
poll_failures: 0,
mode: Mode::Normal,
browse_cache: browse::BrowseCache::default(),
saved: None,
saved_checked: false,
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
picker,
art: None,
art_url: None,
};
let mut last_status = app.status.clone();
let mut status_since = Instant::now();
loop {
let forced = app.last_poll.is_none();
let timer_due = app.last_poll.is_none_or(|t| t.elapsed() >= POLL_INTERVAL);
if forced || (timer_due && app.poll_failures < MAX_POLL_FAILURES) {
poll_playback(&mut app).await;
app.last_poll = Some(Instant::now());
}
if app.status != last_status {
last_status = app.status.clone();
status_since = Instant::now();
}
if app.poll_failures == 0 && !app.status.is_empty() && status_since.elapsed() >= STATUS_TTL
{
app.status.clear();
last_status.clear();
}
terminal.draw(|frame| draw(frame, &mut app))?;
if event::poll(TICK)?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& handle_key(key, &mut app).await
{
break;
}
}
Ok(())
}
async fn handle_key(key: KeyEvent, app: &mut App) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
match app.mode.kind() {
ModeKind::Search => {
handle_search_key(key, app).await;
false
}
ModeKind::Browse => {
handle_browse_key(key, app).await;
false
}
ModeKind::Devices => {
handle_devices_key(key, app).await;
false
}
ModeKind::Help => {
app.mode = Mode::Normal;
false
}
ModeKind::Normal => handle_normal_key(key, app).await,
}
}
async fn handle_normal_key(key: KeyEvent, app: &mut App) -> bool {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Char('/') => app.mode = Mode::Search(SearchState::new()),
KeyCode::Char('2') => load_browse(app, browse::BrowseTab::Playlists).await,
KeyCode::Char('d') => open_devices(app).await,
KeyCode::Char('?') => app.mode = Mode::Help,
KeyCode::Char(' ') => control_toggle(app).await,
KeyCode::Char('n') => control_next(app).await,
KeyCode::Char('p') => control_prev(app).await,
KeyCode::Char('+') | KeyCode::Char('=') => control_volume(app, VOL_STEP).await,
KeyCode::Char('-') | KeyCode::Char('_') => control_volume(app, -VOL_STEP).await,
KeyCode::Left => control_seek(app, -SEEK_STEP_MS).await,
KeyCode::Right => control_seek(app, SEEK_STEP_MS).await,
KeyCode::Char('s') => control_save(app).await,
KeyCode::Char('r') => {
app.poll_failures = 0;
app.last_poll = None;
app.art_url = None;
}
_ => {}
}
false
}
async fn handle_search_key(key: KeyEvent, app: &mut App) {
let action = {
let Mode::Search(state) = &mut app.mode else {
return;
};
search_key_action(key, state)
};
match action {
SearchAction::None => {}
SearchAction::Close => app.mode = Mode::Normal,
SearchAction::Submit(q) => run_search(app, &q).await,
SearchAction::Play { uris, selected } => play_selection(app, &uris, selected).await,
SearchAction::BackToInput => {
if let Mode::Search(state) = &mut app.mode {
state.phase = SearchPhase::Input;
state.results.clear();
state.selected = 0;
state.message = None;
}
}
}
}
fn search_key_action(key: KeyEvent, state: &mut SearchState) -> SearchAction {
match state.phase {
SearchPhase::Input => match key.code {
KeyCode::Esc => SearchAction::Close,
KeyCode::Enter => {
if state.query.trim().is_empty() {
SearchAction::None
} else {
SearchAction::Submit(state.query.clone())
}
}
KeyCode::Backspace => {
state.query.pop();
SearchAction::None
}
KeyCode::Char(c) => {
state.query.push(c);
SearchAction::None
}
_ => SearchAction::None,
},
SearchPhase::Results => match key.code {
KeyCode::Esc => SearchAction::BackToInput,
KeyCode::Up => {
state.selected = state.selected.saturating_sub(1);
SearchAction::None
}
KeyCode::Down => {
if state.selected + 1 < state.results.len() {
state.selected += 1;
}
SearchAction::None
}
KeyCode::Enter => {
if state.results.is_empty() {
SearchAction::None
} else {
let uris = state.results.iter().map(|h| h.uri.clone()).collect();
SearchAction::Play {
uris,
selected: state.selected,
}
}
}
_ => SearchAction::None,
},
}
}
async fn run_search(app: &mut App, q: &str) {
match search_tracks(app, q).await {
Ok(hits) => {
let message = hits
.is_empty()
.then(|| format!("No track found matching \"{q}\""));
app.mode = Mode::Search(SearchState {
query: q.to_string(),
phase: SearchPhase::Results,
results: hits,
selected: 0,
message,
});
}
Err(e) => {
app.status = format!("⚠ search failed: {e}");
if let Mode::Search(state) = &mut app.mode {
state.phase = SearchPhase::Input;
state.message = Some(format!("search failed: {e}"));
}
}
}
}
async fn search_tracks(app: &App, q: &str) -> Result<Vec<TrackHit>> {
auth::ensure_fresh_token(&app.client).await?;
let result = app
.client
.search(q, SearchType::Track, None, None, Some(SEARCH_LIMIT), None)
.await
.context("search failed")?;
let SearchResult::Tracks(page) = result else {
anyhow::bail!("unexpected search result format");
};
Ok(page.items.into_iter().filter_map(track_to_hit).collect())
}
fn track_to_hit(t: FullTrack) -> Option<TrackHit> {
let uri = t.id.as_ref()?.uri();
let artists: Vec<String> = t.artists.into_iter().map(|a| a.name).collect();
Some(TrackHit {
name: t.name,
artists: join_artists(&artists),
uri,
})
}
async fn play_selection(app: &mut App, uris: &[String], selected: usize) {
match start_playback_queue(app, uris, selected).await {
Ok(()) => {
app.status = "▶ Playback started".to_string();
app.last_poll = None; app.mode = Mode::Normal;
}
Err(e) => {
if let Mode::Search(state) = &mut app.mode {
state.message = Some(format!("playback failed: {e}"));
} else {
app.status = format!("⚠ playback failed: {e}");
}
}
}
}
async fn start_playback_queue(app: &App, uris: &[String], selected: usize) -> Result<()> {
let (ids, offset) = queue_from_uris(uris, selected)?;
auth::ensure_fresh_token(&app.client).await?;
app.client
.start_uris_playback(ids.into_iter().map(PlayableId::Track), None, offset, None)
.await
.context("failed to start playback (an active device may be required)")?;
Ok(())
}
fn queue_from_uris(uris: &[String], selected: usize) -> Result<(Vec<TrackId<'_>>, Option<Offset>)> {
let ids = uris
.iter()
.map(|u| TrackId::from_uri(u))
.collect::<Result<Vec<_>, _>>()
.context("failed to parse a track URI")?;
let offset = uris.get(selected).map(|u| Offset::Uri(u.clone()));
Ok((ids, offset))
}
async fn poll_playback(app: &mut App) {
let was_failing = app.poll_failures > 0;
match fetch_playback(app).await {
Ok(Some(np)) => {
let prev_uri = app.now.as_ref().and_then(|n| n.track_uri.clone());
if np.track_uri != prev_uri {
app.saved = None;
app.saved_checked = false;
}
app.now = Some(np);
app.poll_failures = 0;
if was_failing && view::status_kind(&app.status) == view::StatusKind::Warn {
app.status.clear();
}
refresh_saved(app).await;
refresh_art(app).await;
}
Ok(None) => {
app.now = None;
app.saved = None;
app.saved_checked = false;
app.art = None;
app.art_url = None;
app.poll_failures = 0;
if was_failing && view::status_kind(&app.status) == view::StatusKind::Warn {
app.status.clear();
}
}
Err(e) => {
app.poll_failures = app.poll_failures.saturating_add(1);
app.status = if app.poll_failures >= MAX_POLL_FAILURES {
format!("⚠ auto-refresh stopped ({e}). Press r to retry / q to quit")
} else {
format!("⚠ refresh failed: {e}")
};
}
}
}
async fn fetch_playback(app: &App) -> Result<Option<NowPlaying>> {
auth::ensure_fresh_token(&app.client).await?;
let ctx = app
.client
.current_playback(None, None::<Vec<_>>)
.await
.context("failed to fetch playback status")?;
Ok(ctx.map(snapshot_from_context))
}
fn snapshot_from_context(ctx: CurrentPlaybackContext) -> NowPlaying {
let device = ctx.device.name;
let volume = ctx.device.volume_percent.map(|v| v.min(100) as u8);
let progress_ms = ctx
.progress
.map(|d| d.num_milliseconds().max(0) as u128)
.unwrap_or(0);
let is_playing = ctx.is_playing;
let (title, artists, album, duration_ms, track_uri, album_image_url) = match ctx.item {
Some(PlayableItem::Track(t)) => {
let artists: Vec<String> = t.artists.into_iter().map(|a| a.name).collect();
let dur = t.duration.num_milliseconds().max(0) as u128;
let uri = t.id.as_ref().map(|id| id.uri());
let images: Vec<(String, u32, u32)> = t
.album
.images
.into_iter()
.map(|im| (im.url, im.width.unwrap_or(0), im.height.unwrap_or(0)))
.collect();
let art_url = art::pick_image_url(&images);
(t.name, artists, Some(t.album.name), dur, uri, art_url)
}
Some(PlayableItem::Episode(e)) => {
let dur = e.duration.num_milliseconds().max(0) as u128;
(e.name, vec!["(podcast)".to_string()], None, dur, None, None)
}
Some(PlayableItem::Unknown(v)) => {
let (title, artists, album, dur) = crate::commands::status::track_from_json(&v);
let images = crate::commands::status::album_images_from_json(&v);
(
title,
artists,
album,
dur,
crate::commands::status::track_id_from_json(&v),
art::pick_image_url(&images),
)
}
None => (
"(no track info while playing)".to_string(),
Vec::new(),
None,
0,
None,
None,
),
};
NowPlaying {
is_playing,
title,
artists: join_artists(&artists),
album,
progress_ms,
duration_ms,
device,
volume,
track_uri,
album_image_url,
fetched_at: Instant::now(),
}
}
async fn ensure_ready(app: &mut App) -> bool {
match auth::ensure_fresh_token(&app.client).await {
Ok(()) => true,
Err(e) => {
app.status = format!("⚠ {e}");
false
}
}
}
fn finish<E: std::fmt::Display>(app: &mut App, res: Result<(), E>, ok: &str) {
match res {
Ok(()) => {
app.status = ok.to_string();
app.last_poll = None; }
Err(e) => {
app.status =
format!("⚠ operation failed: {e} (press d to select and activate a device)");
}
}
}
async fn control_toggle(app: &mut App) {
let playing = app.now.as_ref().is_some_and(|n| n.is_playing);
if !ensure_ready(app).await {
return;
}
if playing {
let res = app.client.pause_playback(None).await;
finish(app, res, "⏸ Paused");
} else {
let res = app.client.resume_playback(None, None).await;
finish(app, res, "▶ Playing");
}
}
async fn control_next(app: &mut App) {
if !ensure_ready(app).await {
return;
}
let res = app.client.next_track(None).await;
finish(app, res, "⏭ Next track");
}
async fn control_prev(app: &mut App) {
if !ensure_ready(app).await {
return;
}
let res = app.client.previous_track(None).await;
finish(app, res, "⏮ Previous track");
}
async fn control_volume(app: &mut App, delta: i16) {
let Some(cur) = app.now.as_ref().and_then(|n| n.volume) else {
app.status = "⚠ device volume is unavailable (press d to select a device)".to_string();
return;
};
let next = (cur as i16 + delta).clamp(0, 100) as u8;
if !ensure_ready(app).await {
return;
}
let res = app.client.volume(next, None).await;
finish(app, res, &format!("🔊 Volume {next}%"));
}
async fn refresh_saved(app: &mut App) {
if app.saved_checked {
return;
}
let Some(uri) = app.now.as_ref().and_then(|n| n.track_uri.clone()) else {
return; };
let Ok(id) = TrackId::from_uri(&uri) else {
app.saved_checked = true;
return;
};
if auth::ensure_fresh_token(&app.client).await.is_err() {
return;
}
app.saved_checked = true;
if let Ok(mut flags) = app.client.library_contains([LibraryId::Track(id)]).await {
app.saved = flags.pop();
}
}
async fn refresh_art(app: &mut App) {
let url = app.now.as_ref().and_then(|n| n.album_image_url.clone());
if url == app.art_url {
return; }
app.art_url = url.clone();
let Some(url) = url else {
app.art = None; return;
};
match art::fetch_decode(&app.http, &url).await {
Ok(img) => app.art = Some(app.picker.new_resize_protocol(img)),
Err(e) => {
app.art = None;
app.status = format!("⚠ failed to fetch cover art: {e}");
}
}
}
async fn control_seek(app: &mut App, delta_ms: i64) {
let Some(n) = app.now.as_ref() else {
app.status = "⚠ nothing is playing".to_string();
return;
};
let elapsed = n.fetched_at.elapsed().as_millis();
let current = view::interpolate_progress(n.progress_ms, elapsed, n.duration_ms, n.is_playing);
let target = view::seek_target(current, n.duration_ms, delta_ms);
if !ensure_ready(app).await {
return;
}
let res = app
.client
.seek_track(chrono::Duration::milliseconds(target as i64), None)
.await;
match res {
Ok(()) => {
if let Some(n) = app.now.as_mut() {
n.progress_ms = target;
n.fetched_at = Instant::now();
}
app.status = format!("⏩ Seek {}", crate::format::format_ms(target));
}
Err(e) => {
app.status = format!("⚠ seek failed: {e} (press d to select and activate a device)");
}
}
}
async fn control_save(app: &mut App) {
let Some(uri) = app.now.as_ref().and_then(|n| n.track_uri.clone()) else {
app.status = "⚠ cannot save the current track (track info is unknown)".to_string();
return;
};
let id = match TrackId::from_uri(&uri) {
Ok(id) => id,
Err(e) => {
app.status = format!("⚠ failed to parse the track URI: {e}");
return;
}
};
if !ensure_ready(app).await {
return;
}
let want_save = !app.saved.unwrap_or(false);
let res = if want_save {
app.client.library_add([LibraryId::Track(id)]).await
} else {
app.client.library_remove([LibraryId::Track(id)]).await
};
match res {
Ok(()) => {
app.saved = Some(want_save);
app.saved_checked = true;
app.status = if want_save {
"♥ Saved to your library".to_string()
} else {
"♡ Removed from your library".to_string()
};
}
Err(e) => {
app.status = format!("⚠ save operation failed: {e}");
}
}
}
async fn handle_browse_key(key: KeyEvent, app: &mut App) {
let action = {
let Mode::Browse(state) = &mut app.mode else {
return;
};
browse::key_action(key, state)
};
match action {
browse::BrowseAction::None => {}
browse::BrowseAction::Close => app.mode = Mode::Normal,
browse::BrowseAction::Switch(tab) => load_browse(app, tab).await,
browse::BrowseAction::Play => browse_play(app).await,
browse::BrowseAction::Reload => {
let Mode::Browse(state) = &app.mode else {
return;
};
let tab = state.tab;
app.browse_cache.clear(tab);
load_browse(app, tab).await;
}
}
}
async fn load_browse(app: &mut App, tab: browse::BrowseTab) {
let items = match app.browse_cache.get(tab).cloned() {
Some(items) => items,
None => {
match browse::fetch(&app.client, tab).await {
Ok(items) => {
app.browse_cache.set(tab, items.clone());
items
}
Err(e) => {
if let Mode::Browse(state) = &mut app.mode {
state.message = Some(format!("failed to fetch: {e}"));
} else {
app.status = format!("⚠ failed to fetch the library: {e}");
}
return;
}
}
}
};
let message = items
.is_empty()
.then(|| format!("{} is empty", tab.label()));
app.mode = Mode::Browse(browse::BrowseState {
tab,
items,
selected: 0,
message,
});
}
async fn browse_play(app: &mut App) {
let target = match &app.mode {
Mode::Browse(state) => state.items.get(state.selected).map(|it| it.target.clone()),
_ => None,
};
let Some(target) = target else {
return;
};
match browse::play(&app.client, &target).await {
Ok(()) => {
app.status = "▶ Playback started".to_string();
app.last_poll = None;
app.mode = Mode::Normal;
}
Err(e) => {
if let Mode::Browse(state) = &mut app.mode {
state.message = Some(format!("playback failed: {e}"));
} else {
app.status = format!("⚠ playback failed: {e}");
}
}
}
}
async fn handle_devices_key(key: KeyEvent, app: &mut App) {
let action = {
let Mode::Devices(state) = &mut app.mode else {
return;
};
devices::key_action(key, state)
};
match action {
devices::DeviceAction::None => {}
devices::DeviceAction::Close => app.mode = Mode::Normal,
devices::DeviceAction::Transfer => devices_transfer(app).await,
devices::DeviceAction::Reload => open_devices(app).await,
}
}
async fn open_devices(app: &mut App) {
let items = match devices::fetch(&app.client).await {
Ok(items) => items,
Err(e) => {
if let Mode::Devices(state) = &mut app.mode {
state.message = Some(format!("failed to fetch: {e}"));
} else {
app.status = format!("⚠ failed to fetch the device list: {e}");
}
return;
}
};
let message = items
.is_empty()
.then(|| "No playable devices. Please open the Spotify app".to_string());
let selected = items.iter().position(|d| d.is_active).unwrap_or(0);
app.mode = Mode::Devices(devices::DevicePickerState {
items,
selected,
message,
});
}
async fn devices_transfer(app: &mut App) {
let target = match &app.mode {
Mode::Devices(state) => state.items.get(state.selected).cloned(),
_ => None,
};
let Some(target) = target else {
return;
};
if target.is_restricted {
if let Mode::Devices(state) = &mut app.mode {
state.message = Some(format!(
"'{}' is restricted and cannot be transferred to",
target.name
));
}
return;
}
let Some(id) = target.id.as_deref() else {
if let Mode::Devices(state) = &mut app.mode {
state.message = Some(format!(
"'{}' has no ID and cannot be transferred to",
target.name
));
}
return;
};
match devices::transfer(&app.client, id).await {
Ok(()) => {
app.status = format!("▶ Moved playback to '{}'", target.name);
app.last_poll = None; app.mode = Mode::Normal;
}
Err(e) => {
if let Mode::Devices(state) = &mut app.mode {
state.message = Some(format!("transfer failed: {e}"));
} else {
app.status = format!("⚠ transfer failed: {e}");
}
}
}
}
fn setup_terminal() -> Result<Term> {
enable_raw_mode()?;
let mut stdout = io::stdout();
if let Err(e) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(e.into());
}
match Terminal::new(CrosstermBackend::new(stdout)) {
Ok(terminal) => Ok(terminal),
Err(e) => {
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode();
Err(e.into())
}
}
}
fn restore_terminal(terminal: &mut Term) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original(info);
}));
}
fn draw(frame: &mut ratatui::Frame, app: &mut App) {
match app.mode.kind() {
ModeKind::Normal => draw_now(frame, app),
ModeKind::Search => {
if let Mode::Search(state) = &app.mode {
draw_search(frame, state);
}
}
ModeKind::Browse => {
if let Mode::Browse(state) = &app.mode {
draw_browse(frame, state);
}
}
ModeKind::Devices => {
if let Mode::Devices(state) = &app.mode {
draw_devices(frame, state);
}
}
ModeKind::Help => draw_help(frame),
}
}
fn draw_help(frame: &mut ratatui::Frame) {
let area = frame.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(" spotterm — Help ");
let inner = outer.inner(area);
frame.render_widget(outer, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().add_modifier(Modifier::DIM);
let key_width = view::help_entries()
.iter()
.map(|(k, _)| crate::format::display_width(k))
.max()
.unwrap_or(0);
let items: Vec<ListItem> = view::help_entries()
.iter()
.map(|(key, desc)| {
let pad = key_width.saturating_sub(crate::format::display_width(key));
ListItem::new(format!(" {key}{} {desc}", " ".repeat(pad)))
})
.collect();
frame.render_widget(List::new(items).highlight_style(bold), rows[0]);
frame.render_widget(
Paragraph::new("Press any key to go back (Esc / ? / q)")
.alignment(Alignment::Center)
.style(dim),
rows[1],
);
}
fn draw_now(frame: &mut ratatui::Frame, app: &mut App) {
let area = frame.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(" spotterm — Now Playing ");
let inner = outer.inner(area);
frame.render_widget(outer, area);
let want_art_col = app.art.is_some() || app.now.is_some();
let art_cols: u16 = if want_art_col {
inner.height.saturating_mul(2).min(inner.width / 2).min(24)
} else {
0
};
let (art_area, text_area) = if art_cols >= 4 {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(art_cols), Constraint::Min(1)])
.split(inner);
(Some(cols[0]), cols[1])
} else {
(None, inner)
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
.split(text_area);
let elapsed = app
.now
.as_ref()
.map(|n| n.fetched_at.elapsed().as_millis())
.unwrap_or(0);
let v = view::render_lines(
app.now.as_ref(),
elapsed,
text_area.width as usize,
app.saved,
);
let bold = Style::default().add_modifier(Modifier::BOLD);
frame.render_widget(Paragraph::new(v.state).style(bold), rows[0]);
frame.render_widget(Paragraph::new(v.title).style(bold), rows[1]);
frame.render_widget(Paragraph::new(v.artist), rows[2]);
frame.render_widget(Paragraph::new(v.album), rows[3]);
frame.render_widget(
Gauge::default()
.ratio(v.ratio)
.label(v.progress_label)
.use_unicode(true),
rows[4],
);
frame.render_widget(Paragraph::new(v.device), rows[5]);
let (status_text, status_style) = if app.poll_failures >= MAX_POLL_FAILURES {
(
"⚠ auto-refresh is stopped. Press r to retry / q to quit".to_string(),
Style::default().fg(Color::Red),
)
} else {
let style = match view::status_kind(&app.status) {
view::StatusKind::Warn => Style::default().fg(Color::Red),
view::StatusKind::Ok => Style::default().fg(Color::Green),
view::StatusKind::Info => Style::default().add_modifier(Modifier::DIM),
};
(app.status.clone(), style)
};
frame.render_widget(Paragraph::new(status_text).style(status_style), rows[7]);
frame.render_widget(
Paragraph::new("? help q quit")
.alignment(Alignment::Center)
.style(Style::default().add_modifier(Modifier::DIM)),
rows[8],
);
if let Some(area) = art_area {
if let Some(art) = app.art.as_mut() {
frame.render_stateful_widget(StatefulImage::default(), area, art);
} else {
let placeholder = Paragraph::new("♪\n\n(no art)")
.alignment(Alignment::Center)
.style(Style::default().add_modifier(Modifier::DIM))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(placeholder, area);
}
}
}
fn draw_browse(frame: &mut ratatui::Frame, state: &browse::BrowseState) {
let area = frame.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(" spotterm — Library ");
let inner = outer.inner(area);
frame.render_widget(outer, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().add_modifier(Modifier::DIM);
let header = browse::BrowseTab::ALL
.iter()
.map(|t| {
if *t == state.tab {
format!("[{}]", t.label())
} else {
format!(" {} ", t.label())
}
})
.collect::<Vec<_>>()
.join(" ");
frame.render_widget(Paragraph::new(header).style(bold), rows[0]);
let hint = state.message.clone().unwrap_or_else(|| {
format!(
"{} items — ↑↓ select / ←→ tab / Enter play / r refresh / Esc back",
state.items.len()
)
});
frame.render_widget(Paragraph::new(hint).style(dim), rows[1]);
let width = inner.width as usize;
let items: Vec<ListItem> = state
.items
.iter()
.map(|it| ListItem::new(view::search_row(&it.title, &it.subtitle, width)))
.collect();
let mut list_state = ListState::default();
if !state.items.is_empty() {
list_state.select(Some(state.selected));
}
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(bold);
frame.render_stateful_widget(list, rows[2], &mut list_state);
frame.render_widget(
Paragraph::new("↑↓ select ←→ tab Enter play r refresh Esc back Ctrl-C quit")
.alignment(Alignment::Center)
.style(dim),
rows[3],
);
}
fn draw_devices(frame: &mut ratatui::Frame, state: &devices::DevicePickerState) {
let area = frame.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(" spotterm — Devices ");
let inner = outer.inner(area);
frame.render_widget(outer, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().add_modifier(Modifier::DIM);
let hint = state.message.clone().unwrap_or_else(|| {
format!(
"{} devices — ↑↓ select / Enter transfer / r refresh / Esc back",
state.items.len()
)
});
frame.render_widget(Paragraph::new(hint).style(dim), rows[0]);
let width = inner.width as usize;
let items: Vec<ListItem> = state
.items
.iter()
.map(|d| {
ListItem::new(view::device_row(
&d.name,
&d.type_label,
d.volume,
d.is_active,
d.is_restricted,
width,
))
})
.collect();
let mut list_state = ListState::default();
if !state.items.is_empty() {
list_state.select(Some(state.selected));
}
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(bold);
frame.render_stateful_widget(list, rows[1], &mut list_state);
frame.render_widget(
Paragraph::new("↑↓ select Enter transfer r refresh Esc back Ctrl-C quit")
.alignment(Alignment::Center)
.style(dim),
rows[2],
);
}
fn draw_search(frame: &mut ratatui::Frame, state: &SearchState) {
let area = frame.area();
let outer = Block::default()
.borders(Borders::ALL)
.title(" spotterm — Search ");
let inner = outer.inner(area);
frame.render_widget(outer, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let bold = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().add_modifier(Modifier::DIM);
let cursor = if state.phase == SearchPhase::Input {
"▌"
} else {
""
};
frame.render_widget(
Paragraph::new(format!("Search: {}{}", state.query, cursor)).style(bold),
rows[0],
);
let hint = state.message.clone().unwrap_or_else(|| {
view::search_hint(state.phase == SearchPhase::Input, state.results.len())
});
frame.render_widget(Paragraph::new(hint).style(dim), rows[1]);
let width = inner.width as usize;
let items: Vec<ListItem> = state
.results
.iter()
.map(|h| ListItem::new(view::search_row(&h.name, &h.artists, width)))
.collect();
let mut list_state = ListState::default();
if !state.results.is_empty() {
list_state.select(Some(state.selected));
}
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(bold);
frame.render_stateful_widget(list, rows[2], &mut list_state);
frame.render_widget(
Paragraph::new("type → Enter search ↑↓ select Enter play Esc back Ctrl-C quit")
.alignment(Alignment::Center)
.style(dim),
rows[3],
);
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(id: &str) -> String {
format!("spotify:track:{id}")
}
#[test]
fn queue_from_uris_queues_all_hits_and_offsets_to_selection() {
let uris = vec![uri("4iV5W9uYEdYUVa79Axb7Rh"), uri("1301WleyT98MSxVHPZCA6M")];
let (ids, offset) = queue_from_uris(&uris, 1).unwrap();
assert_eq!(ids.len(), 2);
assert_eq!(offset, Some(Offset::Uri(uris[1].clone())));
}
#[test]
fn queue_from_uris_rejects_an_unparseable_uri() {
let uris = vec![uri("4iV5W9uYEdYUVa79Axb7Rh"), "not-a-uri".to_string()];
assert!(queue_from_uris(&uris, 0).is_err());
}
#[test]
fn queue_from_uris_without_a_matching_selection_omits_the_offset() {
let uris = vec![uri("4iV5W9uYEdYUVa79Axb7Rh")];
let (ids, offset) = queue_from_uris(&uris, 9).unwrap();
assert_eq!(ids.len(), 1);
assert_eq!(offset, None);
}
}