use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use id3::frame::Lyrics as Id3Lyrics;
#[allow(unused_imports)]
use termusiclib::config::v2::tui::CoverArtProtocol;
use termusiclib::config::v2::tui::keys::Keys;
use termusiclib::config::v2::tui::theme::ThemeWrap;
use termusiclib::config::{ServerOverlay, SharedServerSettings, SharedTuiSettings, TuiOverlay};
use termusiclib::new_database::Database;
use termusiclib::new_database::track_ops::TrackRead;
use termusiclib::player::playlist_helpers::PlaylistTrackSource;
use termusiclib::player::{PlaylistTracks, RunningStatus};
use termusiclib::podcast::{Podcast, PodcastFeed, db::Database as DBPod};
use termusiclib::songtag::SongTag;
use termusiclib::songtag::lrc::Lyric;
use termusiclib::taskpool::TaskPool;
use termusiclib::track::{LyricData, MediaTypesSimple, Track};
use termusiclib::utils::get_app_config_path;
use termusiclib::xywh;
use tokio::sync::mpsc::{UnboundedSender, unbounded_channel};
use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalBridge};
use super::components::TETrack;
use super::tui_cmd::TuiCmd;
use crate::CombinedSettings;
use crate::ui::Application;
use crate::ui::ids::Id;
use crate::ui::model::ports::stream_events::{PortStreamEvents, WrappedStreamEvents};
use crate::ui::model::youtube_options::YoutubeOptions;
use crate::ui::msg::{ConfigEditorLayout, Msg, SearchCriteria};
#[cfg(all(feature = "cover-ueberzug", not(target_os = "windows")))]
use crate::ui::ueberzug::UeInstance;
pub use download_tracker::DownloadTracker;
pub use user_events::UserEvent;
mod download_tracker;
mod playlist;
mod ports;
mod update;
mod user_events;
mod view;
pub mod youtube_options;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TermusicLayout {
TreeView,
DataBase,
Podcast,
}
#[derive(Debug)]
pub struct DatabaseWidgetData {
pub criteria: SearchCriteria,
pub search_results: Vec<String>,
pub search_tracks: Vec<TrackRead>,
}
impl DatabaseWidgetData {
pub fn reset_search_results(&mut self) {
self.search_results = Vec::new();
self.search_tracks = Vec::new();
}
}
#[derive(Debug)]
pub struct PodcastWidgetData {
pub podcasts: Vec<Podcast>,
pub podcasts_index: usize,
pub db_podcast: DBPod,
pub search_results: Option<Vec<PodcastFeed>>,
}
#[derive(Debug)]
pub struct ConfigEditorData {
pub themes: Vec<String>,
pub theme: ThemeWrap,
pub last_layout: ConfigEditorLayout,
pub key_config: Keys,
pub config_changed: bool,
}
#[derive(Debug, Clone)]
pub struct Playback {
pub playlist: playlist::TUIPlaylist,
status: RunningStatus,
current_track: Option<Track>,
current_track_pos: Duration,
}
impl Playback {
fn new() -> Self {
Self {
playlist: playlist::TUIPlaylist::default(),
status: RunningStatus::default(),
current_track: None,
current_track_pos: Duration::ZERO,
}
}
#[must_use]
pub fn is_stopped(&self) -> bool {
self.status == RunningStatus::Stopped
}
#[must_use]
#[expect(dead_code)]
pub fn is_paused(&self) -> bool {
self.status == RunningStatus::Paused
}
#[must_use]
pub fn status(&self) -> RunningStatus {
self.status
}
pub fn set_status(&mut self, status: RunningStatus) {
self.status = status;
}
#[must_use]
pub fn current_track(&self) -> Option<&Track> {
self.current_track.as_ref()
}
#[must_use]
#[expect(dead_code)]
pub fn current_track_mut(&mut self) -> Option<&mut Track> {
self.current_track.as_mut()
}
pub fn clear_current_track(&mut self) {
self.current_track.take();
}
pub fn set_current_track(&mut self, track: Option<Track>) {
self.current_track = track;
}
pub fn set_current_track_from_playlist(&mut self) {
self.set_current_track(self.playlist.current_track().cloned());
}
pub fn current_track_pos(&self) -> Duration {
self.current_track_pos
}
pub fn set_current_track_pos(&mut self, pos: Duration) {
self.current_track_pos = pos;
}
pub fn load_from_grpc(
&mut self,
info: PlaylistTracks,
podcast_db: &DBPod,
) -> anyhow::Result<()> {
let current_track_index = usize::try_from(info.current_track_index)
.context("convert current_track_index(u64) to usize")?;
let mut playlist_items = Vec::with_capacity(info.tracks.len());
for (idx, track) in info.tracks.into_iter().enumerate() {
let at_index_usize =
usize::try_from(track.at_index).context("convert at_index(u64) to usize")?;
if idx != at_index_usize {
error!("Non-matching \"index\" and \"at_index\"!");
}
let Some(id) = track.id else {
bail!("Track does not have a id, which is required to load!");
};
let track = match PlaylistTrackSource::try_from(id)? {
PlaylistTrackSource::Path(v) => Track::read_track_from_path(v)?,
PlaylistTrackSource::Url(v) => Track::new_radio(&v),
PlaylistTrackSource::PodcastUrl(v) => {
let episode = podcast_db.get_episode_by_url(&v)?;
Track::from_podcast_episode(&episode)
}
};
playlist_items.push(track);
}
self.playlist.set_tracks(playlist_items);
if !self.playlist.is_empty() {
self.playlist.set_current_track_index(current_track_index)?;
}
self.set_current_track_from_playlist();
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtraLyricData {
pub for_track: PathBuf,
pub data: LyricData,
pub selected_idx: usize,
}
impl ExtraLyricData {
pub fn cycle_lyric(&mut self) -> Result<Option<&Id3Lyrics>> {
if self.data.raw_lyrics.is_empty() {
bail!("No lyric frames");
}
self.selected_idx += 1;
if self.selected_idx >= self.data.raw_lyrics.len() {
self.selected_idx = 0;
}
let raw_lyric = self.data.raw_lyrics.get(self.selected_idx);
self.data.parsed_lyrics = raw_lyric.and_then(|v| Lyric::from_str(&v.text).ok());
Ok(raw_lyric)
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TagEditor {
pub song: Option<TETrack>,
pub songtag_results: Vec<SongTag>,
pub has_changed: bool,
}
pub type TxToMain = UnboundedSender<Msg>;
pub struct Model {
pub quit: bool,
pub redraw: bool,
pub app: Application<Id, Msg, UserEvent>,
pub terminal: TerminalBridge<CrosstermTerminalAdapter>,
pub tx_to_main: TxToMain,
pub cmd_to_server_tx: UnboundedSender<TuiCmd>,
pub config_tui: SharedTuiSettings,
pub config_server: SharedServerSettings,
pub db: Database,
pub layout: TermusicLayout,
pub dw: DatabaseWidgetData,
pub podcast: PodcastWidgetData,
pub config_editor: ConfigEditorData,
pub tageditor: TagEditor,
pub current_track_lyric: Option<ExtraLyricData>,
pub playback: Playback,
#[cfg(all(feature = "cover-ueberzug", not(target_os = "windows")))]
pub ueberzug_instance: Option<UeInstance>,
pub viuer_supported: ViuerSupported,
pub xywh: xywh::Xywh,
youtube_options: YoutubeOptions,
pub download_tracker: DownloadTracker,
pub taskpool: TaskPool,
}
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum ViuerSupported {
#[cfg(feature = "cover-viuer-kitty")]
Kitty,
#[cfg(feature = "cover-viuer-iterm")]
ITerm,
#[cfg(feature = "cover-viuer-sixel")]
Sixel,
NotSupported,
}
#[allow(unused)] fn get_viuer_support(config: &TuiOverlay) -> ViuerSupported {
#[cfg(feature = "cover-viuer-kitty")]
if config.cover_protocol_enabled(CoverArtProtocol::Kitty)
&& viuer::KittySupport::None != viuer::get_kitty_support()
{
return ViuerSupported::Kitty;
}
#[cfg(feature = "cover-viuer-iterm")]
if config.cover_protocol_enabled(CoverArtProtocol::Iterm2) && viuer::is_iterm_supported() {
return ViuerSupported::ITerm;
}
#[cfg(feature = "cover-viuer-sixel")]
if config.cover_protocol_enabled(CoverArtProtocol::Sixel) && viuer::is_sixel_supported() {
return ViuerSupported::Sixel;
}
ViuerSupported::NotSupported
}
impl Model {
#[allow(clippy::too_many_lines)]
pub fn new(
config: CombinedSettings,
cmd_to_server_tx: UnboundedSender<TuiCmd>,
stream_updates: WrappedStreamEvents,
) -> Self {
let CombinedSettings {
server: config_server,
tui: config_tui,
} = config;
let path = Self::get_full_path_from_config(&config_server.read());
let config_tui_read = config_tui.read();
let viuer_supported = if config_tui_read.cover_features_enabled() {
get_viuer_support(&config_tui_read)
} else {
ViuerSupported::NotSupported
};
info!("Using viuer protocol {viuer_supported:#?}");
#[cfg(all(feature = "cover-ueberzug", not(target_os = "windows")))]
let ueberzug_instance = if config_tui_read.cover_features_enabled()
&& config_tui_read.cover_protocol_enabled(CoverArtProtocol::Ueberzug)
&& viuer_supported == ViuerSupported::NotSupported
{
Some(UeInstance::default())
} else {
None
};
drop(config_tui_read);
let db = Database::new_default_path().expect("Open Library Database");
let db_criteria = SearchCriteria::Artist;
let terminal = TerminalBridge::new_crossterm().expect("Could not initialize terminal");
let db_path = get_app_config_path().expect("failed to get podcast db path.");
let db_podcast = DBPod::new(&db_path).expect("error connecting to podcast db.");
let podcasts = db_podcast
.get_podcasts()
.expect("failed to get podcasts from db.");
let taskpool = TaskPool::new(usize::from(
config_server
.read()
.settings
.podcast
.concurrent_downloads_max
.get(),
));
let (tx_to_main, rx_to_main) = unbounded_channel();
let stream_update_port = PortStreamEvents::new(stream_updates);
let app = Self::init_app(rx_to_main, stream_update_port);
let ce_theme = config_tui.read().settings.theme.clone();
let xywh = xywh::Xywh::from(&config_tui.read().settings.coverart);
let download_tracker = DownloadTracker::default();
let mut model = Self {
app,
quit: false,
redraw: true,
terminal,
config_server,
config_tui,
tageditor: TagEditor::default(),
youtube_options: YoutubeOptions::default(),
#[cfg(all(feature = "cover-ueberzug", not(target_os = "windows")))]
ueberzug_instance,
viuer_supported,
db,
layout: TermusicLayout::TreeView,
dw: DatabaseWidgetData {
criteria: db_criteria,
search_results: Vec::new(),
search_tracks: Vec::new(),
},
podcast: PodcastWidgetData {
podcasts,
podcasts_index: 0,
db_podcast,
search_results: None,
},
config_editor: ConfigEditorData {
themes: Vec::new(),
theme: ce_theme,
last_layout: ConfigEditorLayout::default(),
key_config: Keys::default(),
config_changed: false,
},
taskpool,
tx_to_main,
download_tracker,
current_track_lyric: None,
playback: Playback::new(),
cmd_to_server_tx,
xywh,
};
model.new_library_scan_dir(path, None);
model
.mount_main()
.expect("Expected all main component to mount correctly");
model
}
#[inline]
pub fn get_combined_settings(&self) -> CombinedSettings {
CombinedSettings {
server: self.config_server.clone(),
tui: self.config_tui.clone(),
}
}
pub fn get_full_path_from_config(config: &ServerOverlay) -> PathBuf {
let mut full_path = String::new();
if let Some(first_music_dir) = config.get_first_music_dir() {
full_path = shellexpand::path::tilde(first_music_dir)
.to_string_lossy()
.to_string();
}
PathBuf::from(full_path)
}
pub fn init(&mut self) {
if let Err(e) = Self::theme_extract_all() {
self.mount_error_popup(e.context("theme save"));
}
self.scan_all_music_roots();
self.playlist_sync();
}
fn scan_all_music_roots(&self) {
let config_server = self.config_server.read();
for dir in &config_server.settings.player.music_dirs {
let absolute_dir = shellexpand::path::tilde(dir);
if let Err(err) = self.db.scan_path(&absolute_dir, &config_server, false) {
error!(
"Error scanning path {:#?}: {err:#?}",
absolute_dir.display()
);
}
}
}
pub fn init_terminal(&mut self) {
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
Self::hook_reset_terminal();
original_hook(panic);
}));
let _drop = self.terminal.enable_raw_mode();
let _drop = self.terminal.enter_alternate_screen();
let _drop = self.terminal.disable_mouse_capture();
let _drop = self.terminal.clear_screen();
crate::TERMINAL_ALTERNATE_MODE.store(true, Ordering::SeqCst);
}
pub fn hook_reset_terminal() {
let mut terminal_clone =
TerminalBridge::new_crossterm().expect("Could not initialize terminal");
let _drop = terminal_clone.disable_raw_mode();
let _drop = terminal_clone.leave_alternate_screen();
crate::TERMINAL_ALTERNATE_MODE.store(false, Ordering::SeqCst);
}
pub fn finalize_terminal(&mut self) {
let _drop = self.terminal.disable_raw_mode();
let _drop = self.terminal.leave_alternate_screen();
crate::TERMINAL_ALTERNATE_MODE.store(false, Ordering::SeqCst);
}
pub fn force_redraw(&mut self) {
self.redraw = true;
}
#[inline]
pub fn request_progress(&mut self) {
self.command(TuiCmd::GetProgress);
}
pub fn player_update_current_track_after(&mut self) {
if let Err(e) = self.update_photo() {
self.mount_error_popup(e.context("update_photo"));
}
self.progress_update_title();
self.lyric_update_title();
self.lyric_update();
self.update_playing_song();
}
pub fn player_toggle_pause(&mut self) {
if self.playback.playlist.is_empty() && self.playback.current_track().is_none() {
return;
}
self.command(TuiCmd::TogglePause);
}
pub fn player_previous(&mut self) {
self.command(TuiCmd::SkipPrevious);
}
pub fn command(&mut self, cmd: TuiCmd) {
if let Err(e) = self.cmd_to_server_tx.send(cmd) {
self.mount_error_popup(anyhow!(e));
}
}
pub fn is_radio(&self) -> bool {
if let Some(track) = self.playback.current_track()
&& track.media_type() == MediaTypesSimple::LiveRadio
{
return true;
}
false
}
}