modified: Cargo.lock

modified:   Cargo.toml
	modified:   src/main.rs
	modified:   src/main_screen.rs
	new file:   src/playlist_icon.rs
This commit is contained in:
Michael Wain 2025-02-11 16:07:18 +03:00
parent f337a6de1f
commit ded8897ece
5 changed files with 243 additions and 96 deletions

21
Cargo.lock generated
View File

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -318,6 +318,15 @@ dependencies = [
"tracing-error", "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]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@ -1311,6 +1320,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"color-eyre", "color-eyre",
"color-thief",
"crossterm", "crossterm",
"dirs", "dirs",
"futures", "futures",
@ -1819,6 +1829,15 @@ dependencies = [
"windows-registry", "windows-registry",
] ]
[[package]]
name = "rgb"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.8" version = "0.17.8"

View File

@ -27,3 +27,4 @@ throbber-widgets-tui = "0.8.0"
image = { version = "0.24.9", default-features = false, features = ["jpeg", "png"] } image = { version = "0.24.9", default-features = false, features = ["jpeg", "png"] }
ureq = "3.0.5" ureq = "3.0.5"
rascii_art = "0.4.5" rascii_art = "0.4.5"
color-thief = "0.2"

View File

@ -1,28 +1,40 @@
use std::{collections::HashMap, error::Error, io}; use std::{collections::HashMap, error::Error, io};
use color_eyre::Result; 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 futures::StreamExt;
use ratatui::{prelude::{Backend, CrosstermBackend}, widgets::Widget, Frame, Terminal};
use main_screen::MainScreen; use main_screen::MainScreen;
use ratatui::{
prelude::{Backend, CrosstermBackend},
widgets::Widget,
Frame, Terminal,
};
use screen::AppScreen; use screen::AppScreen;
use sync::AppEvent; use sync::AppEvent;
use tokio::sync::mpsc::{self, Receiver, UnboundedSender}; use tokio::sync::mpsc::{self, Receiver, UnboundedSender};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use wait_screen::WaitScreen; use wait_screen::WaitScreen;
mod dlp;
mod util;
mod config; mod config;
mod screen; mod dlp;
mod main_screen; mod main_screen;
mod wait_screen; mod playlist_icon;
mod screen;
mod sync; mod sync;
mod util;
mod wait_screen;
#[derive(Eq, Hash, PartialEq)] #[derive(Eq, Hash, PartialEq)]
enum AppState { enum AppState {
IPodWait, IPodWait,
MainScreen MainScreen,
} }
pub struct App { pub struct App {
@ -40,14 +52,20 @@ impl Default for App {
let token = CancellationToken::new(); let token = CancellationToken::new();
sync::initialize_async_service(tx, jr, token.clone()); sync::initialize_async_service(tx, jr, token.clone());
let _ = jx.send(AppEvent::SearchIPod); let _ = jx.send(AppEvent::SearchIPod);
let mut screens: HashMap<AppState, Box<dyn AppScreen>> = HashMap::new(); let mut screens: HashMap<AppState, Box<dyn AppScreen>> = HashMap::new();
screens.insert(AppState::IPodWait, Box::new(WaitScreen::default())); screens.insert(AppState::IPodWait, Box::new(WaitScreen::default()));
screens.insert(AppState::MainScreen, Box::new(MainScreen::new(jx.clone()))); 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); let _ = self.sender.send(AppEvent::SearchIPod);
}, },
AppEvent::ITunesParsed(xdb) => { AppEvent::ITunesParsed(xdb) => {
}, },
AppEvent::SoundcloudGot(playlists) => { AppEvent::SoundcloudGot(playlists) => {
let a = self.screens.get_mut(&AppState::MainScreen).unwrap(); let a = self.screens.get_mut(&AppState::MainScreen).unwrap();
@ -105,8 +123,13 @@ impl App {
} }
fn handle_key_event(&mut self, key_event: KeyEvent) { fn handle_key_event(&mut self, key_event: KeyEvent) {
self.screens.get_mut(&self.state).unwrap().handle_key_event(key_event); self.screens
if let KeyCode::Char('q') = key_event.code { self.exit() } .get_mut(&self.state)
.unwrap()
.handle_key_event(key_event);
if let KeyCode::Char('q') = key_event.code {
self.exit()
}
} }
fn exit(&mut self) { fn exit(&mut self) {
@ -136,4 +159,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?; terminal.show_cursor()?;
Ok(()) Ok(())
} }

View File

@ -1,17 +1,22 @@
use color_eyre::owo_colors::OwoColorize; use color_eyre::owo_colors::OwoColorize;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use rascii_art::{charsets, render_image_to, RenderOptions}; use ratatui::{
use ratatui::{layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, Borders, Gauge, Paragraph, Tabs}, Frame}; 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 soundcloud::sobjects::CloudPlaylists;
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::{screen::AppScreen, sync::AppEvent}; use crate::{playlist_icon::PlaylistIcon, screen::AppScreen, sync::AppEvent};
struct Playlist { struct Playlist {
name: String, name: String,
thumbnail_url: String, thumbnail: PlaylistIcon,
link: String link: String,
} }
pub struct MainScreen { pub struct MainScreen {
@ -21,7 +26,7 @@ pub struct MainScreen {
tab_titles: Vec<String>, tab_titles: Vec<String>,
soundcloud: Option<Vec<Playlist>>, soundcloud: Option<Vec<Playlist>>,
pub progress: Option<(u32, u32)>, pub progress: Option<(u32, u32)>,
sender: UnboundedSender<AppEvent> sender: UnboundedSender<AppEvent>,
} }
impl AppScreen for MainScreen { impl AppScreen for MainScreen {
@ -40,19 +45,26 @@ impl AppScreen for MainScreen {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // Tabs Constraint::Length(3), // Tabs
Constraint::Min(0), // Main content area Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar Constraint::Length(1), // Status bar
]) ])
.split(frame.area()); .split(frame.area());
let tabs = Tabs::new( let tabs = Tabs::new(
self.tab_titles.iter().map(|t| Span::raw(t.clone())).collect::<Vec<Span>>(), self.tab_titles
) .iter()
.block(Block::default().borders(Borders::ALL)) .map(|t| Span::raw(t.clone()))
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .collect::<Vec<Span>>(),
.select(self.selected_tab as usize) )
.style(Style::default().fg(Color::White)); .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]); frame.render_widget(tabs, chunks[0]);
@ -64,13 +76,19 @@ impl AppScreen for MainScreen {
} }
// Render Status Bar // Render Status Bar
let status_bar = Paragraph::new( let status_bar = Paragraph::new(Line::from(vec![
Line::from( "◄ ► to change tab".bold(),
vec!["◄ ► to change tab".bold(), " | ".dark_gray(), "<F5> SAVE FS".bold(), " | ".dark_gray(), "<F6> DL".bold(), " | ".dark_gray(), "<F8> DEL".bold(), " | ".dark_gray(), "<Q> QUIT".bold()] " | ".dark_gray(),
) "<F5> SAVE FS".bold(),
) " | ".dark_gray(),
"<F6> DL".bold(),
" | ".dark_gray(),
"<F8> DEL".bold(),
" | ".dark_gray(),
"<Q> QUIT".bold(),
]))
.centered(); .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 { fn as_any(&mut self) -> &mut dyn std::any::Any {
@ -79,15 +97,20 @@ impl AppScreen for MainScreen {
} }
impl MainScreen { impl MainScreen {
pub fn new( sender: UnboundedSender<AppEvent> ) -> Self { pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
MainScreen { MainScreen {
selected_row: -1, selected_row: -1,
max_rows: 0, max_rows: 0,
soundcloud: None, soundcloud: None,
progress: None, progress: None,
selected_tab: 0, selected_tab: 0,
tab_titles: vec!["YouTube".to_string(), "SoundCloud".to_string(), "Local Playlists".to_string(), "Settings".to_string()], tab_titles: vec![
sender "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) { fn update_max_rows(&mut self) {
self.max_rows = match self.selected_tab { self.max_rows = match self.selected_tab {
1 => self.soundcloud.as_deref().unwrap_or( &[]).len(), 1 => self.soundcloud.as_deref().unwrap_or(&[]).len(),
_ => 0 _ => 0,
}.try_into().unwrap(); }
.try_into()
.unwrap();
} }
fn next_tab(&mut self) { 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(); self.update_max_rows();
} }
fn previous_tab(&mut self) { 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(); self.update_max_rows();
} }
fn previous_row(&mut self) { 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) { fn next_row(&mut self) {
@ -121,42 +149,55 @@ impl MainScreen {
} }
fn download_row(&mut self) { fn download_row(&mut self) {
if self.selected_tab == 1 {// SC if self.selected_tab == 1 {
let playlist_url = self.soundcloud.as_ref().unwrap().get(self.selected_row as usize).unwrap().link.clone(); // 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)); let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist_url));
} }
} }
pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) { pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) {
self.soundcloud = Some( 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 { fn ascii_art_from_url(&self, url: &str) -> PlaylistIcon {
let mut buf = String::new(); let img = image::load_from_memory(
let img = image::load_from_memory(&ureq::get(url).call().unwrap().body_mut().read_to_vec().unwrap() ).unwrap(); &ureq::get(url)
render_image_to( .call()
&img, .unwrap()
&mut buf, .body_mut()
&RenderOptions { .read_to_vec()
width: Some(16), .unwrap(),
height: Some(16), )
colored: true, // true
invert: false,
charset: charsets::BLOCK // BLOCK
})
.unwrap(); .unwrap();
PlaylistIcon::new(img.clone())
buf
} }
fn render_progress(&self, frame: &mut Frame, area: Rect) { fn render_progress(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(0), // Main content Constraint::Min(0), // Main content
Constraint::Length(3), // Progress bar Constraint::Length(3), // Progress bar
]) ])
.split(area); .split(area);
@ -166,16 +207,26 @@ impl MainScreen {
frame.render_widget(main_content, chunks[0]); frame.render_widget(main_content, chunks[0]);
let gauge = Gauge::default() 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)) .gauge_style(Style::default().fg(Color::Green))
.ratio(self.progress.unwrap().0 as f64 / self.progress.unwrap().1 as f64) .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]); frame.render_widget(gauge, chunks[1]);
} }
fn render_tab(&self, frame: &mut Frame, area: Rect) /*-> Table<'_>*/ { fn render_tab(&self, frame: &mut Frame, area: Rect) /*-> Table<'_>*/
if self.selected_tab == 1 { // SC {
if self.selected_tab == 1 {
// SC
/*let mut v = Vec::new(); /*let mut v = Vec::new();
v.push(Row::new(vec!["Id", "Title", "Songs Count", "Date", "IS"]).style(Style::default().fg(Color::Gray))); v.push(Row::new(vec!["Id", "Title", "Songs Count", "Date", "IS"]).style(Style::default().fg(Color::Gray)));
if let Some(s) = &self.soundcloud { if let Some(s) = &self.soundcloud {
@ -183,9 +234,9 @@ impl MainScreen {
let date: DateTime<Utc> = playlist.created_at.parse().unwrap(); let date: DateTime<Utc> = playlist.created_at.parse().unwrap();
let mut row = Row::new( let mut row = Row::new(
vec![ vec![
playlist.id.to_string(), playlist.id.to_string(),
playlist.title.clone(), playlist.title.clone(),
[playlist.track_count.to_string(), " songs".to_string()].concat(), [playlist.track_count.to_string(), " songs".to_string()].concat(),
format!("{}", date.format("%Y-%m-%d %H:%M")), format!("{}", date.format("%Y-%m-%d %H:%M")),
"NO".to_string() "NO".to_string()
] ]
@ -198,11 +249,13 @@ impl MainScreen {
} }
v*/ v*/
let v = self.soundcloud.as_deref().unwrap_or(&[]); let v = self.soundcloud.as_deref().unwrap_or(&[]);
let rows = Layout::default() let rows = Layout::default()
.direction(Direction::Vertical) .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); .split(area);
for (i, row) in rows.iter().enumerate() { for (i, row) in rows.iter().enumerate() {
@ -210,35 +263,34 @@ impl MainScreen {
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.constraints(vec![Constraint::Length(16); 2]) // Three columns .constraints(vec![Constraint::Length(16); 2]) // Three columns
.split(*row); .split(*row);
for (j, col) in cols.iter().enumerate() { for (j, col) in cols.iter().enumerate() {
let index = i * 3 + j; let index = i * 3 + j;
if index < v.len() { if index < v.len() {
let p = &v[index]; 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::<Vec<Line>>(); let s = url_cl.lines().map(Line::from).collect::<Vec<Line>>();
let paragraph = Paragraph::new(s) let paragraph = Paragraph::new(s)
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))
.style(Style::default()); .style(Style::default());*/
frame.render_widget(p.thumbnail.clone(), *col);
frame.render_widget(paragraph, *col);
} }
} }
} }
}; };
// Create the table // Create the table
/* Table::new(rows, &[ /* Table::new(rows, &[
Constraint::Length(3), // ID column Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column Constraint::Percentage(20), // Song count column
Constraint::Percentage(30), Constraint::Percentage(30),
Constraint::Length(2) Constraint::Length(2)
]) ])
.block(Block::default().borders(Borders::ALL).title(" Playlists ")) .block(Block::default().borders(Borders::ALL).title(" Playlists "))
.style(Style::default().fg(Color::White)) */ .style(Style::default().fg(Color::White)) */
} }
} }

52
src/playlist_icon.rs Normal file
View File

@ -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::<HashSet<[u8; 3]>>()
.iter()
.copied()
.collect::<Vec<[u8; 3]>>();
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 };
}
}
}
}