spotatui 0.36.1

A Spotify client for the terminal written in Rust, powered by Ratatui
use anyhow::Result;
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

const REPO_URL: &str = "https://github.com/LargeModGames/spotatui";
const REPO_TAGLINE: &str = "Open-source on GitHub";

#[derive(Clone, Debug)]
pub struct DiscordPlayback {
  pub title: String,
  pub artist: String,
  pub album: String,
  pub state: String,
  pub image_url: Option<String>,
  pub duration_ms: u32,
  pub progress_ms: u128,
  pub is_playing: bool,
}

enum DiscordRpcCommand {
  SetActivity(DiscordPlayback),
  ClearActivity,
}

pub struct DiscordRpcManager {
  command_tx: Sender<DiscordRpcCommand>,
}

impl DiscordRpcManager {
  pub fn new(app_id: String) -> Result<Self> {
    let (command_tx, command_rx) = mpsc::channel();

    thread::spawn(move || run_discord_rpc_loop(app_id, command_rx));

    Ok(Self { command_tx })
  }

  pub fn set_activity(&self, playback: &DiscordPlayback) {
    let _ = self
      .command_tx
      .send(DiscordRpcCommand::SetActivity(playback.clone()));
  }

  pub fn clear(&self) {
    let _ = self.command_tx.send(DiscordRpcCommand::ClearActivity);
  }
}

fn run_discord_rpc_loop(app_id: String, command_rx: Receiver<DiscordRpcCommand>) {
  let mut client: Option<DiscordIpcClient> = None;
  let mut last_connect_attempt = Instant::now() - Duration::from_secs(30);

  for command in command_rx {
    if !ensure_connected(&app_id, &mut client, &mut last_connect_attempt) {
      continue;
    }

    let mut disconnect = false;

    if let Some(ref mut ipc_client) = client {
      let result = match command {
        DiscordRpcCommand::SetActivity(playback) => {
          let activity = build_activity(&playback);
          ipc_client.set_activity(activity)
        }
        DiscordRpcCommand::ClearActivity => ipc_client.clear_activity(),
      };

      if result.is_err() {
        let _ = ipc_client.close();
        disconnect = true;
      }
    }

    if disconnect {
      client = None;
    }
  }

  if let Some(ref mut client) = client {
    let _ = client.clear_activity();
    let _ = client.close();
  }
}

fn ensure_connected(
  app_id: &str,
  client: &mut Option<DiscordIpcClient>,
  last_connect_attempt: &mut Instant,
) -> bool {
  if client.is_some() {
    return true;
  }

  if last_connect_attempt.elapsed() < Duration::from_secs(5) {
    return false;
  }

  *last_connect_attempt = Instant::now();

  let mut new_client = DiscordIpcClient::new(app_id);
  match new_client.connect() {
    Ok(()) => {
      *client = Some(new_client);
      true
    }
    Err(_) => false,
  }
}

fn build_activity(playback: &DiscordPlayback) -> activity::Activity<'_> {
  let mut activity = activity::Activity::new()
    .details(&playback.title)
    .details_url(REPO_URL)
    .state(&playback.state)
    .state_url(REPO_URL)
    .activity_type(activity::ActivityType::Listening);

  if let Some(image_url) = playback.image_url.as_deref() {
    let assets = activity::Assets::new()
      .large_image(image_url)
      .large_text(REPO_URL)
      .small_text(REPO_TAGLINE);
    activity = activity.assets(assets);
  }

  if playback.is_playing && playback.duration_ms > 0 {
    let now_secs = SystemTime::now()
      .duration_since(UNIX_EPOCH)
      .unwrap_or_default()
      .as_secs() as i64;
    let progress_secs = (playback.progress_ms / 1000) as i64;
    let duration_secs = (playback.duration_ms as i64) / 1000;
    let start = now_secs.saturating_sub(progress_secs);
    let end = start.saturating_add(duration_secs);

    let timestamps = activity::Timestamps::new().start(start).end(end);
    activity = activity.timestamps(timestamps);
  }

  activity
}