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.
# 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"

View File

@ -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"

View File

@ -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(())
}
}

View File

@ -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
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 };
}
}
}
}