mod app;
#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))]
mod audio;
mod banner;
mod cli;
mod config;
mod event;
mod handlers;
#[cfg(feature = "mpris")]
mod mpris;
mod network;
#[cfg(feature = "streaming")]
mod player;
mod redirect_uri;
mod ui;
mod user_config;
use crate::app::RouteId;
use crate::event::Key;
use anyhow::{anyhow, Result};
use app::{ActiveBlock, App};
use backtrace::Backtrace;
use banner::BANNER;
use clap::{Arg, Command as ClapApp};
use clap_complete::{generate, Shell};
use config::ClientConfig;
use crossterm::{
cursor::MoveTo,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
style::Print,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
},
ExecutableCommand,
};
use network::{IoEvent, Network};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use redirect_uri::redirect_uri_web_server;
use rspotify::{
prelude::*,
{AuthCodeSpotify, Config, Credentials, OAuth, Token},
};
use std::{
cmp::{max, min},
fs,
io::{self, stdout},
panic::{self, PanicHookInfo},
path::PathBuf,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::SystemTime,
};
use tokio::sync::Mutex;
use user_config::{UserConfig, UserConfigPaths};
const SCOPES: [&str; 15] = [
"playlist-read-collaborative",
"playlist-read-private",
"playlist-modify-private",
"playlist-modify-public",
"user-follow-read",
"user-follow-modify",
"user-library-modify",
"user-library-read",
"user-modify-playback-state",
"user-read-currently-playing",
"user-read-playback-state",
"user-read-playback-position",
"user-read-private",
"user-read-recently-played",
"streaming", ];
async fn save_token_to_file(spotify: &AuthCodeSpotify, path: &PathBuf) -> Result<()> {
let token_lock = spotify.token.lock().await.expect("Failed to lock token");
if let Some(ref token) = *token_lock {
let token_json = serde_json::to_string_pretty(token)?;
fs::write(path, token_json)?;
println!("Token saved to {}", path.display());
}
Ok(())
}
async fn load_token_from_file(spotify: &AuthCodeSpotify, path: &PathBuf) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let token_json = fs::read_to_string(path)?;
let token: Token = serde_json::from_str(&token_json)?;
let mut token_lock = spotify.token.lock().await.expect("Failed to lock token");
*token_lock = Some(token);
drop(token_lock);
println!("Found cached authentication token");
Ok(true)
}
fn close_application() -> Result<()> {
disable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
fn panic_hook(info: &PanicHookInfo<'_>) {
if cfg!(debug_assertions) {
let location = info.location().unwrap();
let msg = match info.payload().downcast_ref::<&'static str>() {
Some(s) => *s,
None => match info.payload().downcast_ref::<String>() {
Some(s) => &s[..],
None => "Box<Any>",
},
};
let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r");
disable_raw_mode().unwrap();
execute!(
io::stdout(),
LeaveAlternateScreen,
Print(format!(
"thread '<unnamed>' panicked at '{}', {}\n\r{}",
msg, location, stacktrace
)),
DisableMouseCapture
)
.unwrap();
}
}
#[tokio::main]
async fn main() -> Result<()> {
panic::set_hook(Box::new(|info| {
panic_hook(info);
}));
let mut clap_app = ClapApp::new(env!("CARGO_PKG_NAME"))
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about(env!("CARGO_PKG_DESCRIPTION"))
.override_usage("Press `?` while running the app to see keybindings")
.before_help(BANNER)
.after_help(
"Your spotify Client ID and Client Secret are stored in $HOME/.config/spotatui/client.yml",
)
.arg(
Arg::new("tick-rate")
.short('t')
.long("tick-rate")
.help("Set the tick rate (milliseconds): the lower the number the higher the FPS.")
.long_help(
"Specify the tick rate in milliseconds: the lower the number the \
higher the FPS. It can be nicer to have a lower value when you want to use the audio analysis view \
of the app. Beware that this comes at a CPU cost!",
),
)
.arg(
Arg::new("config")
.short('c')
.long("config")
.help("Specify configuration file path."),
)
.arg(
Arg::new("completions")
.long("completions")
.help("Generates completions for your preferred shell")
.value_parser(["bash", "zsh", "fish", "power-shell", "elvish"])
.value_name("SHELL"),
)
.subcommand(cli::playback_subcommand())
.subcommand(cli::play_subcommand())
.subcommand(cli::list_subcommand())
.subcommand(cli::search_subcommand())
.subcommand(
ClapApp::new("update")
.version(env!("CARGO_PKG_VERSION"))
.about("Check for and install updates")
.arg(
Arg::new("install")
.short('i')
.long("install")
.action(clap::ArgAction::SetTrue)
.help("Install the update if available"),
),
);
let matches = clap_app.clone().get_matches();
if let Some(s) = matches.get_one::<String>("completions") {
let shell = match s.as_str() {
"fish" => Shell::Fish,
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"power-shell" => Shell::PowerShell,
"elvish" => Shell::Elvish,
_ => return Err(anyhow!("no completions avaible for '{}'", s)),
};
generate(shell, &mut clap_app, "spotatui", &mut io::stdout());
return Ok(());
}
if let Some(update_matches) = matches.subcommand_matches("update") {
let do_install = update_matches.get_flag("install");
return cli::check_for_update(do_install);
}
let mut user_config = UserConfig::new();
if let Some(config_file_path) = matches.get_one::<String>("config") {
let config_file_path = PathBuf::from(config_file_path);
let path = UserConfigPaths { config_file_path };
user_config.path_to_config.replace(path);
}
user_config.load_config()?;
let initial_shuffle_enabled = user_config.behavior.shuffle_enabled;
if matches.subcommand_name().is_none() {
let config_paths_check = match &user_config.path_to_config {
Some(path) => path,
None => {
user_config.get_or_build_paths()?;
user_config.path_to_config.as_ref().unwrap()
}
};
let should_prompt = if config_paths_check.config_file_path.exists() {
let config_string = fs::read_to_string(&config_paths_check.config_file_path)?;
config_string.trim().is_empty() || !config_string.contains("enable_global_song_count")
} else {
let client_yml_path = config_paths_check
.config_file_path
.parent()
.map(|p| p.join("client.yml"));
client_yml_path.is_some_and(|p| p.exists())
};
if should_prompt {
println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("Global Song Counter");
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("\nspotatui can contribute to a global counter showing total");
println!("songs played by all users worldwide.");
println!("\nPrivacy: This feature is completely anonymous.");
println!("• No personal information is collected");
println!("• No song names, artists, or listening history");
println!("• Only a simple increment when a new song starts");
println!("\nWould you like to participate? (Y/n): ");
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
let enable = input.is_empty() || input == "y" || input == "yes";
user_config.behavior.enable_global_song_count = enable;
let config_yml = if config_paths_check.config_file_path.exists() {
fs::read_to_string(&config_paths_check.config_file_path).unwrap_or_default()
} else {
String::new()
};
let mut config: serde_yaml::Value = if config_yml.trim().is_empty() {
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
} else {
serde_yaml::from_str(&config_yml)?
};
if let serde_yaml::Value::Mapping(ref mut map) = config {
let behavior = map
.entry(serde_yaml::Value::String("behavior".to_string()))
.or_insert(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
if let serde_yaml::Value::Mapping(ref mut behavior_map) = behavior {
behavior_map.insert(
serde_yaml::Value::String("enable_global_song_count".to_string()),
serde_yaml::Value::Bool(enable),
);
}
}
let updated_config = serde_yaml::to_string(&config)?;
fs::write(&config_paths_check.config_file_path, updated_config)?;
if enable {
println!("Thank you for participating!\n");
} else {
println!("Opted out. You can change this anytime in ~/.config/spotatui/config.yml\n");
}
}
}
if let Some(tick_rate) = matches
.get_one::<String>("tick-rate")
.and_then(|tick_rate| tick_rate.parse().ok())
{
if tick_rate >= 1000 {
panic!("Tick rate must be below 1000");
} else {
user_config.behavior.tick_rate_milliseconds = tick_rate;
}
}
let mut client_config = ClientConfig::new();
client_config.load_config()?;
let config_paths = client_config.get_or_build_paths()?;
let creds = Credentials::new(&client_config.client_id, &client_config.client_secret);
let oauth = OAuth {
redirect_uri: client_config.get_redirect_uri(),
scopes: SCOPES.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
let config = Config {
cache_path: config_paths.token_cache_path.clone(),
..Default::default()
};
let mut spotify = AuthCodeSpotify::with_config(creds, oauth, config);
let config_port = client_config.get_port();
let needs_auth = match load_token_from_file(&spotify, &config_paths.token_cache_path).await {
Ok(true) => false,
Ok(false) => {
println!("No cached token found, need to authenticate");
true
}
Err(e) => {
println!("Failed to read token cache: {}", e);
true
}
};
if needs_auth {
let auth_url = spotify.get_authorize_url(false)?;
println!("\nAttempting to open this URL in your browser:");
println!("{}\n", auth_url);
if let Err(e) = open::that(&auth_url) {
println!("Failed to open browser automatically: {}", e);
println!("Please manually open the URL above in your browser.");
}
println!(
"Waiting for authorization callback on http://127.0.0.1:{}...\n",
config_port
);
match redirect_uri_web_server(&mut spotify, config_port) {
Ok(url) => {
if let Some(code) = spotify.parse_response_code(&url) {
spotify.request_token(&code).await?;
save_token_to_file(&spotify, &config_paths.token_cache_path).await?;
println!("✓ Successfully authenticated with Spotify!");
} else {
return Err(anyhow!(
"Failed to parse authorization code from callback URL"
));
}
}
Err(()) => {
println!("Starting webserver failed. Continuing with manual authentication");
println!("Please open this URL in your browser: {}", auth_url);
println!("Enter the URL you were redirected to: ");
let mut input = String::new();
io::stdin().read_line(&mut input)?;
if let Some(code) = spotify.parse_response_code(&input) {
spotify.request_token(&code).await?;
save_token_to_file(&spotify, &config_paths.token_cache_path).await?;
} else {
return Err(anyhow!("Failed to parse authorization code from input URL"));
}
}
}
}
let token_lock = spotify.token.lock().await.expect("Failed to lock token");
let token_expiry = if let Some(ref token) = *token_lock {
let expires_in_secs = token.expires_in.num_seconds() as u64;
SystemTime::now()
.checked_add(std::time::Duration::from_secs(expires_in_secs))
.unwrap_or_else(SystemTime::now)
} else {
drop(token_lock);
return Err(anyhow!("Authentication failed: no valid token available"));
};
drop(token_lock);
let (sync_io_tx, sync_io_rx) = std::sync::mpsc::channel::<IoEvent>();
let app = Arc::new(Mutex::new(App::new(
sync_io_tx,
user_config.clone(),
token_expiry,
)));
if let Some(cmd) = matches.subcommand_name() {
let m = matches.subcommand_matches(cmd).unwrap();
#[cfg(feature = "streaming")]
let network = Network::new(spotify, client_config, &app, None); #[cfg(not(feature = "streaming"))]
let network = Network::new(spotify, client_config, &app);
println!(
"{}",
cli::handle_matches(m, cmd.to_string(), network, user_config).await?
);
} else {
#[cfg(feature = "streaming")]
let streaming_player = if client_config.enable_streaming {
let streaming_config = player::StreamingConfig {
device_name: client_config.streaming_device_name.clone(),
bitrate: client_config.streaming_bitrate,
audio_cache: client_config.streaming_audio_cache,
cache_path: player::get_default_cache_path(),
initial_volume: user_config.behavior.volume_percent,
};
let redirect_uri = client_config.get_redirect_uri();
match player::StreamingPlayer::new(&client_config.client_id, &redirect_uri, streaming_config)
.await
{
Ok(p) => {
println!("Streaming player initialized as '{}'", p.device_name());
p.activate();
println!("Activated '{}' as playback device", p.device_name());
Some(Arc::new(p))
}
Err(e) => {
println!("Failed to initialize streaming: {}", e);
println!("Falling back to API-based playback control");
None
}
}
} else {
None
};
#[cfg(feature = "streaming")]
if streaming_player.is_some() {
println!("Native playback enabled - 'spotatui' is now your active playback device");
app.lock().await.is_streaming_active = true;
}
#[cfg(feature = "streaming")]
let streaming_player_clone = streaming_player.clone();
#[cfg(feature = "streaming")]
let streaming_device_name = streaming_player
.as_ref()
.map(|p| p.device_name().to_string());
#[cfg(feature = "streaming")]
let shared_position = Arc::new(AtomicU64::new(0));
#[cfg(feature = "streaming")]
let shared_position_for_events = Arc::clone(&shared_position);
#[cfg(feature = "streaming")]
let shared_position_for_ui = Arc::clone(&shared_position);
#[cfg(feature = "streaming")]
let shared_is_playing = Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(feature = "streaming")]
let shared_is_playing_for_events = Arc::clone(&shared_is_playing);
#[cfg(feature = "mpris")]
let shared_is_playing_for_mpris = Arc::clone(&shared_is_playing);
#[cfg(feature = "mpris")]
let mpris_manager: Option<Arc<mpris::MprisManager>> = if streaming_player.is_some() {
match mpris::MprisManager::new() {
Ok(mgr) => {
println!("MPRIS D-Bus interface registered - media keys and playerctl enabled");
Some(Arc::new(mgr))
}
Err(e) => {
println!(
"Failed to initialize MPRIS: {} - media key control disabled",
e
);
None
}
}
} else {
None
};
#[cfg(feature = "mpris")]
if let Some(ref mpris) = mpris_manager {
if let Some(event_rx) = mpris.take_event_rx() {
let streaming_player_for_mpris = streaming_player.clone();
tokio::spawn(async move {
handle_mpris_events(
event_rx,
streaming_player_for_mpris,
shared_is_playing_for_mpris,
)
.await;
});
}
}
#[cfg(feature = "mpris")]
let mpris_for_events = mpris_manager.clone();
#[cfg(feature = "streaming")]
if let Some(ref player) = streaming_player {
let event_rx = player.get_event_channel();
let app_for_events = Arc::clone(&app);
#[cfg(feature = "mpris")]
tokio::spawn(async move {
handle_player_events(
event_rx,
app_for_events,
shared_position_for_events,
shared_is_playing_for_events,
mpris_for_events,
)
.await;
});
#[cfg(not(feature = "mpris"))]
tokio::spawn(async move {
handle_player_events(
event_rx,
app_for_events,
shared_position_for_events,
shared_is_playing_for_events,
)
.await;
});
}
let cloned_app = Arc::clone(&app);
tokio::spawn(async move {
#[cfg(feature = "streaming")]
let mut network = Network::new(spotify, client_config, &app, streaming_player_clone);
#[cfg(not(feature = "streaming"))]
let mut network = Network::new(spotify, client_config, &app);
#[cfg(feature = "streaming")]
if let Some(device_name) = streaming_device_name {
network
.handle_network_event(IoEvent::AutoSelectStreamingDevice(device_name))
.await;
}
network
.handle_network_event(IoEvent::Shuffle(initial_shuffle_enabled))
.await;
start_tokio(sync_io_rx, &mut network).await;
});
#[cfg(feature = "streaming")]
start_ui(user_config, &cloned_app, Some(shared_position_for_ui)).await?;
#[cfg(not(feature = "streaming"))]
start_ui(user_config, &cloned_app, None).await?;
}
Ok(())
}
async fn start_tokio(io_rx: std::sync::mpsc::Receiver<IoEvent>, network: &mut Network) {
while let Ok(io_event) = io_rx.recv() {
network.handle_network_event(io_event).await;
}
}
#[cfg(all(feature = "streaming", feature = "mpris"))]
async fn handle_player_events(
mut event_rx: librespot_playback::player::PlayerEventChannel,
app: Arc<Mutex<App>>,
shared_position: Arc<AtomicU64>,
shared_is_playing: Arc<std::sync::atomic::AtomicBool>,
mpris_manager: Option<Arc<mpris::MprisManager>>,
) {
use chrono::TimeDelta;
use player::PlayerEvent;
use std::sync::atomic::Ordering;
while let Some(event) = event_rx.recv().await {
match event {
PlayerEvent::Playing {
play_request_id: _,
track_id,
position_ms,
} => {
shared_is_playing.store(true, Ordering::Relaxed);
if let Some(ref mpris) = mpris_manager {
mpris.set_playback_status(true);
}
{
let mut app_lock = app.lock().await;
app_lock.native_is_playing = Some(true);
}
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = true;
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
let track_id_str = track_id.to_string();
if app.last_track_id.as_ref() != Some(&track_id_str) {
app.last_track_id = Some(track_id_str);
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
}
PlayerEvent::Paused {
play_request_id: _,
track_id: _,
position_ms,
} => {
shared_is_playing.store(false, Ordering::Relaxed);
if let Some(ref mpris) = mpris_manager {
mpris.set_playback_status(false);
}
{
let mut app_lock = app.lock().await;
app_lock.native_is_playing = Some(false);
}
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = false;
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
}
}
PlayerEvent::Seeked {
play_request_id: _,
track_id: _,
position_ms,
} => {
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
app.seek_ms = None;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
}
}
PlayerEvent::TrackChanged { audio_item } => {
use librespot_metadata::audio::UniqueFields;
let (artists, album) = match &audio_item.unique_fields {
UniqueFields::Track { artists, album, .. } => {
let artist_names: Vec<String> = artists.0.iter().map(|a| a.name.clone()).collect();
(artist_names, album.clone())
}
UniqueFields::Episode { show_name, .. } => (vec![show_name.clone()], String::new()),
UniqueFields::Local { artists, album, .. } => {
let artist_vec = artists
.as_ref()
.map(|a| vec![a.clone()])
.unwrap_or_default();
let album_str = album.clone().unwrap_or_default();
(artist_vec, album_str)
}
};
if let Some(ref mpris) = mpris_manager {
mpris.set_metadata(&audio_item.name, &artists, &album, audio_item.duration_ms);
}
if let Ok(mut app) = app.try_lock() {
app.native_track_info = Some(app::NativeTrackInfo {
name: audio_item.name.clone(),
artists: artists.clone(),
album: album.clone(),
duration_ms: audio_item.duration_ms,
});
app.song_progress_ms = 0;
app.last_track_id = Some(audio_item.track_id.to_string());
app.instant_since_last_current_playback_poll = std::time::Instant::now();
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
PlayerEvent::Stopped { .. } | PlayerEvent::EndOfTrack { .. } => {
if let Some(ref mpris) = mpris_manager {
mpris.set_stopped();
}
if let Ok(mut app) = app.try_lock() {
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = false;
}
app.song_progress_ms = 0;
app.last_track_id = None;
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Ok(mut app) = app.try_lock() {
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
PlayerEvent::VolumeChanged { volume } => {
let volume_percent = ((volume as f64 / 65535.0) * 100.0).round() as u8;
if let Some(ref mpris) = mpris_manager {
mpris.set_volume(volume_percent);
}
if let Ok(mut app) = app.try_lock() {
if let Some(ref mut ctx) = app.current_playback_context {
ctx.device.volume_percent = Some(volume_percent as u32);
}
app.user_config.behavior.volume_percent = volume_percent.min(100);
let _ = app.user_config.save_config();
}
}
PlayerEvent::PositionChanged {
play_request_id: _,
track_id: _,
position_ms,
} => {
shared_position.store(position_ms as u64, Ordering::Relaxed);
}
_ => {
}
}
}
}
#[cfg(all(feature = "streaming", not(feature = "mpris")))]
async fn handle_player_events(
mut event_rx: librespot_playback::player::PlayerEventChannel,
app: Arc<Mutex<App>>,
shared_position: Arc<AtomicU64>,
shared_is_playing: Arc<std::sync::atomic::AtomicBool>,
) {
use chrono::TimeDelta;
use player::PlayerEvent;
use std::sync::atomic::Ordering;
while let Some(event) = event_rx.recv().await {
match event {
PlayerEvent::Playing {
play_request_id: _,
track_id,
position_ms,
} => {
shared_is_playing.store(true, Ordering::Relaxed);
{
let mut app_lock = app.lock().await;
app_lock.native_is_playing = Some(true);
}
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = true;
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
let track_id_str = track_id.to_string();
if app.last_track_id.as_ref() != Some(&track_id_str) {
app.last_track_id = Some(track_id_str);
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
}
PlayerEvent::Paused {
play_request_id: _,
track_id: _,
position_ms,
} => {
shared_is_playing.store(false, Ordering::Relaxed);
{
let mut app_lock = app.lock().await;
app_lock.native_is_playing = Some(false);
}
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = false;
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
}
}
PlayerEvent::Seeked {
play_request_id: _,
track_id: _,
position_ms,
} => {
if let Ok(mut app) = app.try_lock() {
app.song_progress_ms = position_ms as u128;
app.seek_ms = None;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.progress = Some(TimeDelta::milliseconds(position_ms as i64));
}
app.instant_since_last_current_playback_poll = std::time::Instant::now();
}
}
PlayerEvent::TrackChanged { audio_item } => {
if let Ok(mut app) = app.try_lock() {
use librespot_metadata::audio::UniqueFields;
let (artists, album) = match &audio_item.unique_fields {
UniqueFields::Track { artists, album, .. } => {
let artist_names: Vec<String> = artists.0.iter().map(|a| a.name.clone()).collect();
(artist_names, album.clone())
}
UniqueFields::Episode { show_name, .. } => (vec![show_name.clone()], String::new()),
UniqueFields::Local { artists, album, .. } => {
let artist_vec = artists
.as_ref()
.map(|a| vec![a.clone()])
.unwrap_or_default();
let album_str = album.clone().unwrap_or_default();
(artist_vec, album_str)
}
};
app.native_track_info = Some(app::NativeTrackInfo {
name: audio_item.name.clone(),
artists,
album,
duration_ms: audio_item.duration_ms,
});
app.song_progress_ms = 0;
app.last_track_id = Some(audio_item.track_id.to_string());
app.instant_since_last_current_playback_poll = std::time::Instant::now();
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
PlayerEvent::Stopped { .. } | PlayerEvent::EndOfTrack { .. } => {
if let Ok(mut app) = app.try_lock() {
if let Some(ref mut ctx) = app.current_playback_context {
ctx.is_playing = false;
}
app.song_progress_ms = 0;
app.last_track_id = None;
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
if let Ok(mut app) = app.try_lock() {
app.dispatch(IoEvent::GetCurrentPlayback);
}
}
PlayerEvent::VolumeChanged { volume } => {
if let Ok(mut app) = app.try_lock() {
let volume_percent = ((volume as f64 / 65535.0) * 100.0).round() as u32;
if let Some(ref mut ctx) = app.current_playback_context {
ctx.device.volume_percent = Some(volume_percent);
}
app.user_config.behavior.volume_percent = volume_percent.min(100) as u8;
let _ = app.user_config.save_config();
}
}
PlayerEvent::PositionChanged {
play_request_id: _,
track_id: _,
position_ms,
} => {
shared_position.store(position_ms as u64, Ordering::Relaxed);
}
_ => {}
}
}
}
#[cfg(feature = "mpris")]
async fn handle_mpris_events(
mut event_rx: tokio::sync::mpsc::UnboundedReceiver<mpris::MprisEvent>,
streaming_player: Option<Arc<player::StreamingPlayer>>,
shared_is_playing: Arc<std::sync::atomic::AtomicBool>,
) {
use mpris::MprisEvent;
use std::sync::atomic::Ordering;
let Some(player) = streaming_player else {
return;
};
while let Some(event) = event_rx.recv().await {
match event {
MprisEvent::PlayPause => {
if shared_is_playing.load(Ordering::Relaxed) {
player.pause();
} else {
player.play();
}
}
MprisEvent::Play => {
player.play();
}
MprisEvent::Pause => {
player.pause();
}
MprisEvent::Next => {
player.next();
player.play();
}
MprisEvent::Previous => {
player.prev();
player.play();
}
MprisEvent::Stop => {
player.stop();
}
MprisEvent::Seek(offset_micros) => {
let offset_ms = (offset_micros / 1000) as u32;
player.seek(offset_ms);
}
}
}
}
async fn start_ui(
user_config: UserConfig,
app: &Arc<Mutex<App>>,
shared_position: Option<Arc<AtomicU64>>,
) -> Result<()> {
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
enable_raw_mode()?;
let mut backend = CrosstermBackend::new(stdout);
if user_config.behavior.set_window_title {
backend.execute(SetTitle("spt - spotatui"))?;
}
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let events = event::Events::new(user_config.behavior.tick_rate_milliseconds);
#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))]
let mut audio_capture: Option<audio::AudioCaptureManager> = None;
{
let update_info = tokio::task::spawn_blocking(cli::check_for_update_silent)
.await
.ok()
.flatten();
if let Some(info) = update_info {
let mut app = app.lock().await;
app.update_available = Some(info);
app.push_navigation_stack(RouteId::UpdatePrompt, ActiveBlock::UpdatePrompt);
}
}
let mut is_first_render = true;
loop {
let mut app = app.lock().await;
if let Ok(size) = terminal.backend().size() {
if is_first_render || app.size != size {
app.help_menu_max_lines = 0;
app.help_menu_offset = 0;
app.help_menu_page = 0;
app.size = size;
let potential_limit = max((app.size.height as i32) - 13, 0) as u32;
let max_limit = min(potential_limit, 50);
let large_search_limit = min((f32::from(size.height) / 1.4) as u32, max_limit);
let small_search_limit = min((f32::from(size.height) / 2.85) as u32, max_limit / 2);
app.dispatch(IoEvent::UpdateSearchLimits(
large_search_limit,
small_search_limit,
));
if app.size.height > 8 {
app.help_menu_max_lines = (app.size.height as u32) - 8;
} else {
app.help_menu_max_lines = 0;
}
}
};
let current_route = app.get_current_route();
terminal.draw(|f| match current_route.active_block {
ActiveBlock::HelpMenu => {
ui::draw_help_menu(f, &app);
}
ActiveBlock::Error => {
ui::draw_error_screen(f, &app);
}
ActiveBlock::SelectDevice => {
ui::draw_device_list(f, &app);
}
ActiveBlock::Analysis => {
ui::audio_analysis::draw(f, &app);
}
ActiveBlock::BasicView => {
ui::draw_basic_view(f, &app);
}
ActiveBlock::UpdatePrompt => {
ui::draw_update_prompt(f, &app);
}
ActiveBlock::Settings => {
ui::settings::draw_settings(f, &app);
}
_ => {
ui::draw_main_layout(f, &app);
}
})?;
if current_route.active_block == ActiveBlock::Input {
terminal.show_cursor()?;
} else {
terminal.hide_cursor()?;
}
let cursor_offset = if app.size.height > ui::util::SMALL_TERMINAL_HEIGHT {
2
} else {
1
};
terminal.backend_mut().execute(MoveTo(
cursor_offset + app.input_cursor_position,
cursor_offset,
))?;
if SystemTime::now() > app.spotify_token_expiry {
app.dispatch(IoEvent::RefreshAuthentication);
}
match events.next()? {
event::Event::Input(key) => {
if key == Key::Ctrl('c') {
app.close_io_channel();
break;
}
let current_active_block = app.get_current_route().active_block;
if current_active_block == ActiveBlock::Input {
handlers::input_handler(key, &mut app);
} else if key == app.user_config.keys.back {
if app.get_current_route().active_block != ActiveBlock::Input {
let pop_result = match app.pop_navigation_stack() {
Some(ref x) if x.id == RouteId::Search => app.pop_navigation_stack(),
Some(x) => Some(x),
None => None,
};
if pop_result.is_none() {
app.close_io_channel();
break; }
}
} else {
handlers::handle_app(key, &mut app);
}
}
event::Event::Tick => {
app.update_on_tick();
if let Some(ref pos) = shared_position {
if app.is_streaming_active {
let position_ms = pos.load(Ordering::Relaxed);
if position_ms > 0 {
app.song_progress_ms = position_ms as u128;
}
}
}
#[cfg(any(feature = "audio-viz", feature = "audio-viz-cpal"))]
{
let in_analysis_view = app.get_current_route().active_block == ActiveBlock::Analysis;
if in_analysis_view {
if audio_capture.is_none() {
audio_capture = audio::AudioCaptureManager::new();
app.audio_capture_active = audio_capture.is_some();
}
if let Some(ref capture) = audio_capture {
if let Some(spectrum) = capture.get_spectrum() {
app.spectrum_data = Some(app::SpectrumData {
bands: spectrum.bands,
peak: spectrum.peak,
});
app.audio_capture_active = capture.is_active();
}
}
} else {
if audio_capture.is_some() {
audio_capture = None;
app.audio_capture_active = false;
app.spectrum_data = None;
}
}
}
}
}
if is_first_render {
app.dispatch(IoEvent::GetPlaylists);
app.dispatch(IoEvent::GetUser);
app.dispatch(IoEvent::GetCurrentPlayback);
app.help_docs_size = ui::help::get_help_docs(&app.user_config.keys).len() as u32;
is_first_render = false;
}
}
terminal.show_cursor()?;
close_application()?;
Ok(())
}