plavno 0.4.0

A lightweight terminal music player for Linux, macOS and Windows with service waves and library shuffle
mod artwork;
mod audio;
mod backend;
mod browser_login;
mod cached_frame;
mod choreography;
mod config;
mod controls;
mod dance_balance;
mod dance_motion;
mod dance_space;
mod dancer;
mod dislikes;
mod engine;
mod flow_view;
mod history;
mod history_view;
mod local_metadata;
#[cfg(target_os = "macos")]
mod macos_media;
#[cfg(target_os = "linux")]
mod mpris;
#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
#[path = "mpris_stub.rs"]
mod mpris;
mod sixel;
mod source_view;
mod spring;
mod stream;
mod terminal_input;
mod text_selection;
mod tui;
mod visualization;
mod wave;
#[cfg(windows)]
mod windows_media;

use anyhow::{Result, bail};
use std::{
    io::{self, IsTerminal},
    path::PathBuf,
};

const VERSION: &str = env!("CARGO_PKG_VERSION");

#[cfg(target_os = "macos")]
use macos_media::MediaControls;
#[cfg(not(any(target_os = "macos", windows)))]
use mpris::Mpris as MediaControls;
#[cfg(windows)]
use windows_media::MediaControls;

fn main() {
    if let Err(error) = run() {
        eprintln!("plavno: {error:#}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let args: Vec<_> = std::env::args_os().skip(1).collect();
    #[cfg(all(target_os = "macos", feature = "test-audio"))]
    if args.len() == 1 && args[0] == "--test-media-controls" {
        return macos_media::regression::run();
    }
    #[cfg(all(windows, feature = "test-audio"))]
    if args.len() == 1 && args[0] == "--test-media-controls" {
        return windows_media::regression::run();
    }
    if args.len() == 1 && (args[0] == "--version" || args[0] == "-V") {
        println!("plavno {VERSION}");
        return Ok(());
    }
    if args.len() == 1 && args[0] == "--stream-audio" {
        return stream::run();
    }
    if args
        .first()
        .is_some_and(|arg| arg == "--help" || arg == "-h")
    {
        println!(
            "plavno {VERSION} — a lightweight music TUI\n\n  plavno --version              show version\n  plavno                        play the first connected service\n  plavno --source SOURCE        yandex / soundcloud / spotify / vk / ytmusic / local / mixed\n  plavno --seed ID_OR_URL       override track radio for this run (--source required)\n  plavno --favorites            shuffle favorites (Yandex; --source vk: My Music; ytmusic: Liked Music)\n  plavno --favorites-newest     play favorites from newest to oldest\n  plavno --no-wave              start with visualization hidden\n  plavno --dance                show the wave and dancing silhouette\n  plavno --dance-style STYLE    auto / flow / groove / pulse / house / hiphop / rave\n  plavno --renderer MODE        dancer output: auto / braille / sixel\n  plavno --dance-skin SKIN       sixel appearance: detailed (default) / silhouette\n  plavno --sixel-update MODE    diff (default) / full\n  plavno login [SOURCE]         connect a service (default: yandex; SoundCloud/VK: browser)\n  plavno login yandex           paste a Yandex Music OAuth token\n  plavno login SOURCE --manual  paste a JSON profile without browser automation\n  plavno diagnose yandex       check API access and audio CDN connectivity\n  plavno diagnose vk           inspect the VK response layout (no session values)\n  plavno sources                list service configuration\n  plavno local DIRECTORY        save the local music directory (recursive shuffle)\n  plavno --directory DIRECTORY  use a local directory for this run\n  plavno --file FILE            play a local file\n\nSpace pause · x stop · p/n tracks · ←/→ ±5s · Shift+←/→ ±30s\nClick controls · drag progress · scroll bar to seek, Speed to change rate, elsewhere volume · h or / history\nHistory: type to search · ↑/↓ select · Enter play · Esc back\ns sources · m Wave Shuffle · w restart wave · f Favorites Shuffle · Shift+F newest favorites\ng Flow settings · v toggle visualization · b wave/dance · l ♥/♡ favorites · d dislike · u undo dislike\n+/- volume · [/] playback speed · q quit"
        );
        return Ok(());
    }
    if args.len() == 1 && args[0] == "login" {
        return config::login();
    }
    if let [command, source] = args.as_slice()
        && command == "login"
    {
        let source = source.to_str().unwrap_or_default();
        return if source == "yandex" {
            config::login()
        } else {
            config::login_provider(source)
        };
    }
    if let [command, source, flag] = args.as_slice()
        && command == "login"
        && flag == "--manual"
    {
        return config::login_manual(source.to_str().unwrap_or_default());
    }
    if let [command, source] = args.as_slice()
        && command == "diagnose"
    {
        let report = match source.to_str() {
            Some("yandex") => config::yandex_with_token(config::load()?).diagnose(),
            Some("vk") => backend::Vk::new(config::profile_path("vk")?).diagnose()?,
            _ => bail!("diagnose requires yandex or vk"),
        };
        println!("{}", serde_json::to_string_pretty(&report)?);
        return Ok(());
    }
    if args.len() == 1 && args[0] == "sources" {
        let (backends, services) = config::backends()?;
        let sources = backend::Multi::new(backends, services, None)?;
        for source in backend::Backend::services(&sources) {
            println!("{:<12} {:<16} {}", source.key, source.name, source.status);
        }
        return Ok(());
    }
    if let [command, directory] = args.as_slice()
        && command == "local"
    {
        let directory = config::save_local_directory(std::path::Path::new(directory))?;
        println!(
            "Local music: {}\nPlay with: plavno --source local",
            directory.display()
        );
        return Ok(());
    }
    let mut favorites = None;
    let mut no_wave = false;
    let mut visual_mode = visualization::Mode::Wave;
    let mut dance_style = dance_motion::Style::Auto;
    let mut renderer = visualization::Renderer::Braille;
    let mut sixel_update = sixel::UpdateMode::default();
    let mut skin = dancer::skin::Skin::default();
    let mut selected = None;
    let mut seed = None;
    let mut file = None;
    let mut directory = None;
    let mut arguments = args.iter();
    while let Some(arg) = arguments.next() {
        if arg == "--favorites" {
            favorites = Some(backend::PlaybackMode::FavoritesShuffle);
        } else if arg == "--favorites-newest" {
            favorites = Some(backend::PlaybackMode::FavoritesNewest);
        } else if arg == "--no-wave" {
            no_wave = true;
        } else if arg == "--dance" {
            visual_mode = visualization::Mode::Dance;
        } else if arg == "--dance-style" {
            dance_style = arguments
                .next()
                .and_then(|arg| arg.to_str())
                .and_then(dance_motion::Style::parse)
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "--dance-style requires auto, flow, groove, pulse, house, hiphop, or rave"
                    )
                })?;
            visual_mode = visualization::Mode::Dance;
        } else if arg == "--renderer" {
            renderer = match arguments.next().and_then(|arg| arg.to_str()) {
                Some("auto" | "braille") => visualization::Renderer::Braille,
                Some("sixel") => visualization::Renderer::Sixel,
                _ => bail!("--renderer requires auto, braille, or sixel"),
            };
        } else if arg == "--dance-skin" {
            skin = match arguments.next().and_then(|arg| arg.to_str()) {
                Some("detailed") => dancer::skin::Skin::Detailed,
                Some("silhouette") => dancer::skin::Skin::Silhouette,
                _ => bail!("--dance-skin requires detailed or silhouette"),
            };
        } else if arg == "--sixel-update" {
            sixel_update = match arguments.next().and_then(|arg| arg.to_str()) {
                Some("full") => sixel::UpdateMode::Full,
                Some("diff") => sixel::UpdateMode::Diff,
                _ => bail!("--sixel-update requires full or diff"),
            };
        } else if arg == "--source" {
            selected = Some(
                arguments
                    .next()
                    .and_then(|a| a.to_str())
                    .ok_or_else(|| anyhow::anyhow!("--source requires a source name"))?,
            );
        } else if arg == "--seed" {
            seed = Some(
                arguments
                    .next()
                    .and_then(|arg| arg.to_str())
                    .filter(|seed| !seed.trim().is_empty() && !seed.starts_with("--"))
                    .ok_or_else(|| anyhow::anyhow!("--seed requires a track ID or URL"))?,
            );
        } else if arg == "--directory" {
            directory = Some(PathBuf::from(arguments.next().ok_or_else(|| {
                anyhow::anyhow!("--directory requires a directory path")
            })?));
        } else if arg == "--file" {
            file =
                Some(PathBuf::from(arguments.next().ok_or_else(|| {
                    anyhow::anyhow!("--file requires a file path")
                })?));
        } else {
            bail!("Unknown arguments; run plavno --help");
        }
    }
    let seed = if let Some(seed) = seed {
        anyhow::ensure!(
            favorites.is_none(),
            "--seed cannot be combined with --favorites or --favorites-newest"
        );
        Some((
            selected.ok_or_else(|| {
                anyhow::anyhow!("--seed requires --source ytmusic, soundcloud or spotify")
            })?,
            seed,
        ))
    } else {
        None
    };
    let backend: Box<dyn backend::Backend> = if let Some(file) = file {
        anyhow::ensure!(
            selected.is_none() && favorites.is_none() && directory.is_none() && seed.is_none(),
            "--file cannot be combined with --source, --directory, --seed, --favorites or --favorites-newest"
        );
        Box::new(backend::Local(file))
    } else {
        if directory.is_some() && selected.is_none() {
            selected = Some("local");
        }
        if favorites.is_some() && selected.is_none() {
            selected = Some("yandex");
        }
        let (backends, services) = config::backends_with_overrides(directory, seed)?;
        Box::new(backend::Multi::new(backends, services, selected)?)
    };
    anyhow::ensure!(
        io::stdin().is_terminal() && io::stdout().is_terminal(),
        "The TUI requires an interactive terminal"
    );
    // Initialize WinRT before WASAPI chooses a COM apartment for this thread.
    #[cfg(windows)]
    let _media_runtime = windows_media::Runtime::new()?;
    let (mut engine, events, receiver) = engine::Engine::new(backend)?;
    if let Some(mode) = favorites {
        engine.mode = mode;
    }
    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
    let media_controls = match MediaControls::new(&engine, events.clone()) {
        Ok(controls) => Some(controls),
        Err(error) => {
            eprintln!("plavno: desktop media controls unavailable: {error:#}");
            None
        }
    };
    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
    let media_controls = None;
    tui::run(
        engine,
        events,
        receiver,
        media_controls,
        !no_wave,
        visualization::Options {
            mode: visual_mode,
            style: dance_style,
            renderer,
            sixel_update,
            skin,
        },
    )
}