From ded8897ece6702a96b6a8300019c2e1b319b626b Mon Sep 17 00:00:00 2001 From: "alterwain@protonmail.com" Date: Tue, 11 Feb 2025 16:07:18 +0300 Subject: [PATCH] modified: Cargo.lock modified: Cargo.toml modified: src/main.rs modified: src/main_screen.rs new file: src/playlist_icon.rs --- Cargo.lock | 21 ++++- Cargo.toml | 1 + src/main.rs | 51 ++++++++--- src/main_screen.rs | 214 +++++++++++++++++++++++++++---------------- src/playlist_icon.rs | 52 +++++++++++ 5 files changed, 243 insertions(+), 96 deletions(-) create mode 100644 src/playlist_icon.rs diff --git a/Cargo.lock b/Cargo.lock index 1267ae3..5966747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -318,6 +318,15 @@ dependencies = [ "tracing-error", ] +[[package]] +name = "color-thief" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6460d760cf38ce67c9e0318f896538820acc54f2d0a3bfc5b2c557211066c98" +dependencies = [ + "rgb", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -1311,6 +1320,7 @@ version = "0.1.0" dependencies = [ "chrono", "color-eyre", + "color-thief", "crossterm", "dirs", "futures", @@ -1819,6 +1829,15 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.8" diff --git a/Cargo.toml b/Cargo.toml index 115fb64..c27913d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ throbber-widgets-tui = "0.8.0" image = { version = "0.24.9", default-features = false, features = ["jpeg", "png"] } ureq = "3.0.5" rascii_art = "0.4.5" +color-thief = "0.2" \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4116b4e..aea9569 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,40 @@ use std::{collections::HashMap, error::Error, io}; use color_eyre::Result; -use crossterm::{event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}}; +use crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, + KeyEventKind, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; use futures::StreamExt; -use ratatui::{prelude::{Backend, CrosstermBackend}, widgets::Widget, Frame, Terminal}; use main_screen::MainScreen; +use ratatui::{ + prelude::{Backend, CrosstermBackend}, + widgets::Widget, + Frame, Terminal, +}; use screen::AppScreen; use sync::AppEvent; use tokio::sync::mpsc::{self, Receiver, UnboundedSender}; use tokio_util::sync::CancellationToken; use wait_screen::WaitScreen; -mod dlp; -mod util; mod config; -mod screen; +mod dlp; mod main_screen; -mod wait_screen; +mod playlist_icon; +mod screen; mod sync; +mod util; +mod wait_screen; #[derive(Eq, Hash, PartialEq)] enum AppState { IPodWait, - MainScreen + MainScreen, } pub struct App { @@ -40,14 +52,20 @@ impl Default for App { let token = CancellationToken::new(); sync::initialize_async_service(tx, jr, token.clone()); - + let _ = jx.send(AppEvent::SearchIPod); - + let mut screens: HashMap> = HashMap::new(); screens.insert(AppState::IPodWait, Box::new(WaitScreen::default())); screens.insert(AppState::MainScreen, Box::new(MainScreen::new(jx.clone()))); - Self { receiver: rx, sender: jx, token, state: AppState::IPodWait, screens } + Self { + receiver: rx, + sender: jx, + token, + state: AppState::IPodWait, + screens, + } } } @@ -85,7 +103,7 @@ impl App { let _ = self.sender.send(AppEvent::SearchIPod); }, AppEvent::ITunesParsed(xdb) => { - + }, AppEvent::SoundcloudGot(playlists) => { let a = self.screens.get_mut(&AppState::MainScreen).unwrap(); @@ -105,8 +123,13 @@ impl App { } fn handle_key_event(&mut self, key_event: KeyEvent) { - self.screens.get_mut(&self.state).unwrap().handle_key_event(key_event); - if let KeyCode::Char('q') = key_event.code { self.exit() } + self.screens + .get_mut(&self.state) + .unwrap() + .handle_key_event(key_event); + if let KeyCode::Char('q') = key_event.code { + self.exit() + } } fn exit(&mut self) { @@ -136,4 +159,4 @@ async fn main() -> Result<(), Box> { terminal.show_cursor()?; Ok(()) -} \ No newline at end of file +} diff --git a/src/main_screen.rs b/src/main_screen.rs index 0d19594..afd2c9a 100644 --- a/src/main_screen.rs +++ b/src/main_screen.rs @@ -1,17 +1,22 @@ use color_eyre::owo_colors::OwoColorize; use crossterm::event::{KeyCode, KeyEvent}; -use rascii_art::{charsets, render_image_to, RenderOptions}; -use ratatui::{layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Gauge, Paragraph, Tabs}, Frame}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, Paragraph, Tabs}, + Frame, +}; use soundcloud::sobjects::CloudPlaylists; use strum::IntoEnumIterator; use tokio::sync::mpsc::UnboundedSender; -use crate::{screen::AppScreen, sync::AppEvent}; +use crate::{playlist_icon::PlaylistIcon, screen::AppScreen, sync::AppEvent}; struct Playlist { name: String, - thumbnail_url: String, - link: String + thumbnail: PlaylistIcon, + link: String, } pub struct MainScreen { @@ -21,7 +26,7 @@ pub struct MainScreen { tab_titles: Vec, soundcloud: Option>, pub progress: Option<(u32, u32)>, - sender: UnboundedSender + sender: UnboundedSender, } impl AppScreen for MainScreen { @@ -40,19 +45,26 @@ impl AppScreen for MainScreen { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Tabs - Constraint::Min(0), // Main content area - Constraint::Length(1), // Status bar + Constraint::Length(3), // Tabs + Constraint::Min(0), // Main content area + Constraint::Length(1), // Status bar ]) .split(frame.area()); - + let tabs = Tabs::new( - self.tab_titles.iter().map(|t| Span::raw(t.clone())).collect::>(), - ) - .block(Block::default().borders(Borders::ALL)) - .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) - .select(self.selected_tab as usize) - .style(Style::default().fg(Color::White)); + self.tab_titles + .iter() + .map(|t| Span::raw(t.clone())) + .collect::>(), + ) + .block(Block::default().borders(Borders::ALL)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .select(self.selected_tab as usize) + .style(Style::default().fg(Color::White)); frame.render_widget(tabs, chunks[0]); @@ -64,13 +76,19 @@ impl AppScreen for MainScreen { } // Render Status Bar - let status_bar = Paragraph::new( - Line::from( - vec!["◄ ► to change tab".bold(), " | ".dark_gray(), " SAVE FS".bold(), " | ".dark_gray(), " DL".bold(), " | ".dark_gray(), " DEL".bold(), " | ".dark_gray(), " QUIT".bold()] - ) - ) + let status_bar = Paragraph::new(Line::from(vec![ + "◄ ► to change tab".bold(), + " | ".dark_gray(), + " SAVE FS".bold(), + " | ".dark_gray(), + " DL".bold(), + " | ".dark_gray(), + " DEL".bold(), + " | ".dark_gray(), + " QUIT".bold(), + ])) .centered(); - frame.render_widget(status_bar, chunks[2]); // Render into third chunk + frame.render_widget(status_bar, chunks[2]); // Render into third chunk } fn as_any(&mut self) -> &mut dyn std::any::Any { @@ -79,15 +97,20 @@ impl AppScreen for MainScreen { } impl MainScreen { - pub fn new( sender: UnboundedSender ) -> Self { - MainScreen { - selected_row: -1, - max_rows: 0, + pub fn new(sender: UnboundedSender) -> Self { + MainScreen { + selected_row: -1, + max_rows: 0, soundcloud: None, - progress: None, - selected_tab: 0, - tab_titles: vec!["YouTube".to_string(), "SoundCloud".to_string(), "Local Playlists".to_string(), "Settings".to_string()], - sender + progress: None, + selected_tab: 0, + tab_titles: vec![ + "YouTube".to_string(), + "SoundCloud".to_string(), + "Local Playlists".to_string(), + "Settings".to_string(), + ], + sender, } } @@ -97,23 +120,28 @@ impl MainScreen { fn update_max_rows(&mut self) { self.max_rows = match self.selected_tab { - 1 => self.soundcloud.as_deref().unwrap_or( &[]).len(), - _ => 0 - }.try_into().unwrap(); + 1 => self.soundcloud.as_deref().unwrap_or(&[]).len(), + _ => 0, + } + .try_into() + .unwrap(); } fn next_tab(&mut self) { - self.selected_tab = std::cmp::min(self.selected_tab+1, (self.tab_titles.len()-1).try_into().unwrap()); + self.selected_tab = std::cmp::min( + self.selected_tab + 1, + (self.tab_titles.len() - 1).try_into().unwrap(), + ); self.update_max_rows(); } fn previous_tab(&mut self) { - self.selected_tab = std::cmp::max(0, self.selected_tab-1); + self.selected_tab = std::cmp::max(0, self.selected_tab - 1); self.update_max_rows(); } fn previous_row(&mut self) { - self.selected_row = std::cmp::max(0, self.selected_row-1); + self.selected_row = std::cmp::max(0, self.selected_row - 1); } fn next_row(&mut self) { @@ -121,42 +149,55 @@ impl MainScreen { } fn download_row(&mut self) { - if self.selected_tab == 1 {// SC - let playlist_url = self.soundcloud.as_ref().unwrap().get(self.selected_row as usize).unwrap().link.clone(); + if self.selected_tab == 1 { + // SC + let playlist_url = self + .soundcloud + .as_ref() + .unwrap() + .get(self.selected_row as usize) + .unwrap() + .link + .clone(); let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist_url)); } } pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) { self.soundcloud = Some( - pl.collection.iter().map(|p| Playlist { name: p.title.clone(), thumbnail_url: p.artwork_url.as_deref().map_or(String::new(), |u| self.ascii_art_from_url(u)), link: p.permalink_url.clone() }).collect() + pl.collection + .iter() + .map(|p| Playlist { + name: p.title.clone(), + thumbnail: p + .artwork_url + .as_deref() + .map_or(PlaylistIcon::default(), |u| self.ascii_art_from_url(u)), + link: p.permalink_url.clone(), + }) + .collect(), ); } - fn ascii_art_from_url(&self, url: &str) -> String { - let mut buf = String::new(); - let img = image::load_from_memory(&ureq::get(url).call().unwrap().body_mut().read_to_vec().unwrap() ).unwrap(); - render_image_to( - &img, - &mut buf, - &RenderOptions { - width: Some(16), - height: Some(16), - colored: true, // true - invert: false, - charset: charsets::BLOCK // BLOCK - }) + fn ascii_art_from_url(&self, url: &str) -> PlaylistIcon { + let img = image::load_from_memory( + &ureq::get(url) + .call() + .unwrap() + .body_mut() + .read_to_vec() + .unwrap(), + ) .unwrap(); - - buf + PlaylistIcon::new(img.clone()) } fn render_progress(&self, frame: &mut Frame, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(0), // Main content - Constraint::Length(3), // Progress bar + Constraint::Min(0), // Main content + Constraint::Length(3), // Progress bar ]) .split(area); @@ -166,16 +207,26 @@ impl MainScreen { frame.render_widget(main_content, chunks[0]); let gauge = Gauge::default() - .block(Block::default().borders(Borders::ALL).title(" Downloading Playlist ")) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Downloading Playlist "), + ) .gauge_style(Style::default().fg(Color::Green)) .ratio(self.progress.unwrap().0 as f64 / self.progress.unwrap().1 as f64) - .label(format!("{:}/{:}", self.progress.unwrap().0, self.progress.unwrap().1)); + .label(format!( + "{:}/{:}", + self.progress.unwrap().0, + self.progress.unwrap().1 + )); frame.render_widget(gauge, chunks[1]); } - fn render_tab(&self, frame: &mut Frame, area: Rect) /*-> Table<'_>*/ { - if self.selected_tab == 1 { // SC + fn render_tab(&self, frame: &mut Frame, area: Rect) /*-> Table<'_>*/ + { + if self.selected_tab == 1 { + // SC /*let mut v = Vec::new(); v.push(Row::new(vec!["Id", "Title", "Songs Count", "Date", "IS"]).style(Style::default().fg(Color::Gray))); if let Some(s) = &self.soundcloud { @@ -183,9 +234,9 @@ impl MainScreen { let date: DateTime = playlist.created_at.parse().unwrap(); let mut row = Row::new( vec![ - playlist.id.to_string(), - playlist.title.clone(), - [playlist.track_count.to_string(), " songs".to_string()].concat(), + playlist.id.to_string(), + playlist.title.clone(), + [playlist.track_count.to_string(), " songs".to_string()].concat(), format!("{}", date.format("%Y-%m-%d %H:%M")), "NO".to_string() ] @@ -198,11 +249,13 @@ impl MainScreen { } v*/ let v = self.soundcloud.as_deref().unwrap_or(&[]); - let rows = Layout::default() .direction(Direction::Vertical) - .constraints(vec![Constraint::Percentage(100); math::round::ceil(v.len() as f64 / 3_f64, 0) as usize]) // Two rows + .constraints(vec![ + Constraint::Percentage(100); + math::round::ceil(v.len() as f64 / 3_f64, 0) as usize + ]) // Two rows .split(area); for (i, row) in rows.iter().enumerate() { @@ -210,35 +263,34 @@ impl MainScreen { .direction(Direction::Horizontal) .constraints(vec![Constraint::Length(16); 2]) // Three columns .split(*row); - + for (j, col) in cols.iter().enumerate() { let index = i * 3 + j; if index < v.len() { let p = &v[index]; - let url_cl = p.thumbnail_url.clone(); + /*let url_cl = p.thumbnail_url.clone(); let s = url_cl.lines().map(Line::from).collect::>(); let paragraph = Paragraph::new(s) .block(Block::default().borders(Borders::ALL)) - .style(Style::default()); - - - frame.render_widget(paragraph, *col); + .style(Style::default());*/ + + frame.render_widget(p.thumbnail.clone(), *col); } } } }; // Create the table - /* Table::new(rows, &[ - Constraint::Length(3), // ID column - Constraint::Percentage(50), // Playlist name column - Constraint::Percentage(20), // Song count column - Constraint::Percentage(30), - Constraint::Length(2) - ]) - .block(Block::default().borders(Borders::ALL).title(" Playlists ")) - .style(Style::default().fg(Color::White)) */ + /* Table::new(rows, &[ + Constraint::Length(3), // ID column + Constraint::Percentage(50), // Playlist name column + Constraint::Percentage(20), // Song count column + Constraint::Percentage(30), + Constraint::Length(2) + ]) + .block(Block::default().borders(Borders::ALL).title(" Playlists ")) + .style(Style::default().fg(Color::White)) */ } -} \ No newline at end of file +} diff --git a/src/playlist_icon.rs b/src/playlist_icon.rs new file mode 100644 index 0000000..884994d --- /dev/null +++ b/src/playlist_icon.rs @@ -0,0 +1,52 @@ +use std::collections::HashSet; + +use color_eyre::owo_colors::OwoColorize; +use image::{DynamicImage, GenericImageView}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style, Stylize}, + widgets::Widget, +}; + +#[derive(Default, Clone)] +pub struct PlaylistIcon { + colors: [[u8; 3]; 8], +} + +impl PlaylistIcon { + pub fn new(img: DynamicImage) -> Self { + let pixels = img + .resize_exact(8, 8, image::imageops::FilterType::Nearest) + .to_rgb8() + .pixels() + .map(|p| p.0) + .collect::>() + .iter() + .copied() + .collect::>(); + + Self { + colors: pixels[..8].try_into().unwrap(), + } + } +} + +impl Widget for PlaylistIcon { + fn render(self, area: Rect, buf: &mut Buffer) { + let mut i = 0; + + for x in area.left()..area.right() { + for y in area.top()..area.bottom() { + let color = self.colors[i]; + buf.set_string( + x, + y, + "█", + Style::default().fg(Color::Rgb(color[0], color[1], color[2])), + ); + i = if i >= self.colors.len() - 1 { 0 } else { i + 1 }; + } + } + } +}