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:
parent
f337a6de1f
commit
ded8897ece
21
Cargo.lock
generated
21
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
51
src/main.rs
51
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<AppState, Box<dyn AppScreen>> = 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<dyn Error>> {
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
@ -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<String>,
|
||||
soundcloud: Option<Vec<Playlist>>,
|
||||
pub progress: Option<(u32, u32)>,
|
||||
sender: UnboundedSender<AppEvent>
|
||||
sender: UnboundedSender<AppEvent>,
|
||||
}
|
||||
|
||||
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::<Vec<Span>>(),
|
||||
)
|
||||
.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::<Vec<Span>>(),
|
||||
)
|
||||
.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(), "<F5> SAVE FS".bold(), " | ".dark_gray(), "<F6> DL".bold(), " | ".dark_gray(), "<F8> DEL".bold(), " | ".dark_gray(), "<Q> QUIT".bold()]
|
||||
)
|
||||
)
|
||||
let status_bar = Paragraph::new(Line::from(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(),
|
||||
]))
|
||||
.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<AppEvent> ) -> Self {
|
||||
MainScreen {
|
||||
selected_row: -1,
|
||||
max_rows: 0,
|
||||
pub fn new(sender: UnboundedSender<AppEvent>) -> 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<Utc> = 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::<Vec<Line>>();
|
||||
|
||||
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)) */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
52
src/playlist_icon.rs
Normal file
52
src/playlist_icon.rs
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user