use super::appevent::{AppEvent, EventHandler};
use crate::config::ApiKey;
use crate::core::get_limited_sequential_file;
use crate::{RuntimeInfo, get_data_dir};
use anyhow::{Context, Result, bail};
use async_callback_manager::{AsyncCallbackManager, TaskOutcome};
use component::actionhandler::YoutuiEffect;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use media_controls::MediaController;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui_image::picker::Picker;
use server::{ArcServer, Server, TaskMetadata};
use std::borrow::Cow;
use std::fmt::Display;
use std::io;
use std::sync::Arc;
use structures::ListSong;
use tracing::{debug, error, info};
use tracing_subscriber::prelude::*;
use ui::{WindowContext, YoutuiWindow};
#[macro_use]
pub mod component;
mod media_controls;
mod server;
mod structures;
pub mod ui;
pub mod view;
thread_local! {
static IS_MAIN_THREAD: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
const CALLBACK_CHANNEL_SIZE: usize = 64;
const EVENT_CHANNEL_SIZE: usize = 256;
const LOG_FILE_NAME: &str = "debug";
const LOG_FILE_EXT: &str = "log";
const MAX_LOG_FILES: u16 = 5;
pub struct Youtui {
status: AppStatus,
event_handler: EventHandler,
window_state: YoutuiWindow,
task_manager: AsyncCallbackManager<YoutuiWindow, ArcServer, TaskMetadata>,
server: Arc<Server>,
terminal: Terminal<CrosstermBackend<io::Stdout>>,
media_controls: Option<MediaController>,
terminal_image_capabilities: Picker,
}
#[derive(PartialEq)]
pub enum AppStatus {
Running,
Exiting(Cow<'static, str>),
}
#[derive(Debug)]
#[must_use]
pub enum AppCallback {
Quit,
ChangeContext(WindowContext),
AddSongsToPlaylist(Vec<ListSong>),
AddSongsToPlaylistAndPlay(Vec<ListSong>),
}
impl Youtui {
pub async fn new(rt: RuntimeInfo) -> Result<Youtui> {
let RuntimeInfo {
api_key,
debug,
po_token,
config,
disable_media_controls,
} = rt;
init_tracing(debug, true).await?;
match debug {
true => info!("Starting in debug mode"),
false => info!("Starting"),
}
if let ApiKey::None = api_key {
bail!("Authentication is required to run youtui");
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture,)?;
IS_MAIN_THREAD.with(|flag| flag.set(true));
std::panic::set_hook(Box::new(|panic_info| {
if IS_MAIN_THREAD.with(|flag| flag.get()) {
tracing::error!(
"Panic detected on main thread. \
Message: {panic_info}"
);
let _ = cleanup_tui_and_print_panic_message(&panic_info);
} else {
tracing::warn!(
"Panic detected outside main thread - \
this is not necessarily an error but may indicate one. \
Message: {panic_info}"
);
}
}));
let mut task_manager = async_callback_manager::AsyncCallbackManager::new()
.with_on_task_spawn_callback(|task| {
info!(
"Received task {:?}: type_id: {:?}, constraint: {:?}",
task.type_debug, task.type_id, task.constraint
)
});
let server = Arc::new(server::Server::new(api_key, po_token, &config));
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let terminal_image_capabilities = Picker::from_query_stdio()?;
debug!("Terminal info: {terminal_image_capabilities:#?}");
let (media_controls, media_control_event_stream) = if disable_media_controls {
(None, None)
} else {
let (media_controls, media_control_event_stream) = MediaController::new().context(
"Unable to initialise media controls - is the application already running?",
)?;
(Some(media_controls), Some(media_control_event_stream))
};
let event_handler = EventHandler::new(EVENT_CHANNEL_SIZE, media_control_event_stream)?;
let (window_state, effect) = YoutuiWindow::new(config);
task_manager.spawn_task(&server, effect);
Ok(Youtui {
status: AppStatus::Running,
event_handler,
window_state,
task_manager,
server,
terminal,
media_controls,
terminal_image_capabilities,
})
}
pub async fn run(&mut self) -> Result<()> {
loop {
match &self.status {
AppStatus::Running => {
self.terminal.draw(|f| {
ui::draw::draw_app(
f,
&mut self.window_state,
&self.terminal_image_capabilities,
);
})?;
if let Some(media_controls) = &mut self.media_controls {
media_controls.update_controls(
ui::draw_media_controls::draw_app_media_controls(&self.window_state),
)?;
}
tokio::select! {
Some(event) = self.event_handler.next() =>
self.handle_event(event).await,
Some(outcome) = self.task_manager.get_next_response() =>
self.handle_effect(outcome),
}
}
AppStatus::Exiting(s) => {
destruct_terminal()?;
println!("{s}");
break;
}
}
}
Ok(())
}
fn handle_effect(&mut self, effect: TaskOutcome<YoutuiWindow, ArcServer, TaskMetadata>) {
match effect {
async_callback_manager::TaskOutcome::StreamFinished {
type_id,
type_debug,
task_id,
..
} => {
info!(
"Stream task {:?}: type_id: {:?}, task_id: {:?} finished",
type_debug, type_id, task_id
);
}
async_callback_manager::TaskOutcome::TaskPanicked {
type_debug, error, ..
}
| async_callback_manager::TaskOutcome::StreamPanicked {
type_debug, error, ..
} => {
error!("Task {type_debug} panicked!");
let _ = cleanup_tui_and_print_panic_message(&error);
std::panic::resume_unwind(error.into_panic());
}
async_callback_manager::TaskOutcome::MutationReceived {
mutation,
type_id,
type_debug,
task_id,
..
} => {
info!(
"Received response to {:?}: type_id: {:?}, task_id: {:?}",
type_debug, type_id, task_id
);
let next_task = mutation(&mut self.window_state);
self.task_manager.spawn_task(&self.server, next_task);
}
}
}
async fn handle_event(&mut self, event: AppEvent) {
match event {
AppEvent::Tick => self.window_state.handle_tick().await,
AppEvent::Crossterm(e) => {
let YoutuiEffect { effect, callback } =
self.window_state.handle_crossterm_event(e).await;
self.task_manager.spawn_task(&self.server, effect);
if let Some(callback) = callback {
self.handle_callback(callback);
}
}
AppEvent::MediaControls(e) => {
let YoutuiEffect { effect, callback } =
self.window_state.handle_media_controls_event(e).await;
self.task_manager.spawn_task(&self.server, effect);
if let Some(callback) = callback {
self.handle_callback(callback);
}
}
AppEvent::QuitSignal => self.status = AppStatus::Exiting("Quit signal received".into()),
}
}
pub fn handle_callback(&mut self, callback: AppCallback) {
match callback {
AppCallback::Quit => self.status = AppStatus::Exiting("Quitting".into()),
AppCallback::ChangeContext(context) => self.window_state.handle_change_context(context),
AppCallback::AddSongsToPlaylist(song_list) => self.task_manager.spawn_task(
&self.server,
self.window_state.handle_add_songs_to_playlist(song_list),
),
AppCallback::AddSongsToPlaylistAndPlay(song_list) => self.task_manager.spawn_task(
&self.server,
self.window_state
.handle_add_songs_to_playlist_and_play(song_list),
),
}
}
}
fn cleanup_tui_and_print_panic_message(panic: &impl Display) -> Result<()> {
destruct_terminal()?;
println!("{panic}");
Ok(())
}
fn destruct_terminal() -> Result<()> {
disable_raw_mode()?;
execute!(
io::stdout(),
LeaveAlternateScreen,
DisableMouseCapture,
crossterm::cursor::Show
)?;
Ok(())
}
async fn init_tracing(debug: bool, logging: bool) -> Result<()> {
let tui_logger_layer = tui_logger::TuiTracingSubscriberLayer;
let (tracing_log_level, tui_logger_log_level) = if debug {
(tracing::Level::DEBUG, tui_logger::LevelFilter::Debug)
} else {
(tracing::Level::INFO, tui_logger::LevelFilter::Info)
};
let context_layer =
tracing_subscriber::filter::Targets::new().with_target("youtui", tracing_log_level);
if logging {
let (log_file, log_file_name) = get_limited_sequential_file(
&get_data_dir()?,
LOG_FILE_NAME,
LOG_FILE_EXT,
MAX_LOG_FILES,
)
.await?;
let log_file_layer = tracing_subscriber::fmt::layer().with_writer(Arc::new(
log_file
.try_into_std()
.expect("No file operation should be in-flight yet"),
));
tracing_subscriber::registry()
.with(tui_logger_layer.and_then(log_file_layer))
.with(context_layer)
.init();
info!("Logging to {:?}.", log_file_name);
} else {
let context_layer =
tracing_subscriber::filter::Targets::new().with_target("youtui", tracing_log_level);
tracing_subscriber::registry()
.with(tui_logger_layer)
.with(context_layer)
.init();
}
tui_logger::init_logger(tui_logger_log_level)
.expect("Expected logger to initialise succesfully");
Ok(())
}