Scrolling through list added, splash screen modified.

This commit is contained in:
Michael Wain 2025-02-17 19:23:30 +03:00
parent 6b8b4ef355
commit 3bd8f1c75d
7 changed files with 316 additions and 236 deletions

78
Cargo.lock generated

@ -301,6 +301,37 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn",
]
[[package]]
name = "dirs"
version = "6.0.0"
@ -405,6 +436,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]]
name = "font8x8"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e"
[[package]]
name = "foreign-types"
version = "0.3.2"
@ -928,6 +965,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.14"
@ -936,8 +982,8 @@ checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "itunesdb"
version = "0.1.50"
source = "git+https://gitea.awain.net/alterwain/ITunesDB.git#2c38d91cd89908b20a2b08da1b3202dacf5d1f9b"
version = "0.1.51"
source = "git+https://gitea.awain.net/alterwain/ITunesDB.git#ed639f96d59b6777114551e8c3054f2a0ac9c7da"
dependencies = [
"bincode",
"env_logger",
@ -1047,9 +1093,11 @@ dependencies = [
"serde",
"serde_json",
"soundcloud",
"throbber-widgets-tui",
"tokio",
"tokio-util",
"toml",
"tui-big-text",
]
[[package]]
@ -1344,7 +1392,7 @@ dependencies = [
"crossterm",
"indoc",
"instability",
"itertools",
"itertools 0.13.0",
"lru",
"paste",
"strum",
@ -1893,6 +1941,16 @@ dependencies = [
"once_cell",
]
[[package]]
name = "throbber-widgets-tui"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d36b5738d666a2b4c91b7c24998a8588db724b3107258343ebf8824bf55b06d"
dependencies = [
"rand",
"ratatui",
]
[[package]]
name = "time"
version = "0.3.37"
@ -2094,6 +2152,18 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tui-big-text"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97cefa9f1425ab6146db2961241cec86845d11105b5dd6bb504294b0cdd21af"
dependencies = [
"derive_builder",
"font8x8",
"itertools 0.14.0",
"ratatui",
]
[[package]]
name = "unicode-ident"
version = "1.0.16"
@ -2112,7 +2182,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]

@ -20,7 +20,9 @@ futures = "0.3"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7.12", features = ["codec"] }
soundcloud = { version = "0.1.8", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
itunesdb = { version = "0.1.50", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }
itunesdb = { version = "0.1.51", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }
puremp3 = "0.1.0"
mp3-duration = "0.1.10"
rand = "0.8.5"
rand = "0.8.5"
tui-big-text = "0.7.1"
throbber-widgets-tui = "0.8.0"

@ -4,7 +4,9 @@ pub mod table {
use ratatui::widgets::{Block, Borders, Row, Table};
use ratatui::Frame;
#[derive(Default)]
pub struct SmartTable {
is_checked: bool,
header: Vec<String>,
data: Vec<Vec<String>>,
constraints: Vec<Constraint>,
@ -15,6 +17,7 @@ pub mod table {
impl SmartTable {
pub fn new(header: Vec<String>, constraints: Vec<Constraint>) -> Self {
Self {
is_checked: true,
header,
data: Vec::new(),
constraints,
@ -23,6 +26,10 @@ pub mod table {
}
}
pub fn set_checked(&mut self, checked: bool) {
self.is_checked = checked;
}
pub fn set_data(&mut self, data: Vec<Vec<String>>) {
self.data = data;
}
@ -49,14 +56,22 @@ pub mod table {
for (i, entry) in self.data.iter().enumerate() {
v.push(
Row::new(entry.clone()).style(if self.selected_row as usize == i {
Style::default().bg(Color::LightBlue).fg(Color::White)
Style::default()
.bg(if self.is_checked {
Color::LightBlue
} else {
Color::Gray
})
.fg(Color::White)
} else {
Style::default()
}),
);
}
if self.selected_row as usize > area.rows().count() - 4 {
if self.selected_row as usize > area.rows().count() - 4
&& self.selected_row - ((area.rows().count() - 4) as i32) >= 0
{
v = v[(self.selected_row as usize - (area.rows().count() - 4))..].to_vec();
}

@ -45,7 +45,7 @@ impl AppScreen for FileSystem {
}
}
fn render(&self, frame: &mut ratatui::Frame, theme: &Theme) {
fn render(&self, frame: &mut Frame, _theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([

@ -9,7 +9,7 @@ use crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use futures::{StreamExt, TryStreamExt};
use loading_screen::LoadingScreen;
use main_screen::MainScreen;
use ratatui::{
@ -18,6 +18,7 @@ use ratatui::{
Frame, Terminal,
};
use screen::AppScreen;
use std::time::Duration;
use std::{collections::HashMap, error::Error, io};
use sync::AppEvent;
use tokio::sync::mpsc::{self, Receiver, UnboundedSender};
@ -134,6 +135,9 @@ impl App {
_ => {}
}
}
_ = tokio::time::sleep(Duration::from_millis(200)) => {
}
}
}

@ -4,22 +4,21 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Row, Table, Tabs},
widgets::{Block, Borders, Paragraph, Tabs},
Frame,
};
use soundcloud::sobjects::{CloudPlaylist, CloudPlaylists};
use tokio::sync::mpsc::UnboundedSender;
use crate::component::table::SmartTable;
use crate::sync::DBPlaylist;
use crate::{screen::AppScreen, sync::AppEvent, theme::Theme, AppState};
pub struct MainScreen {
mode: bool,
selected_tab: i8,
selected_playlist: i32,
selected_song: i32,
max_pls: i32,
max_songs: i32,
pl_table: SmartTable,
song_table: SmartTable,
tab_titles: Vec<String>,
soundcloud: Option<Vec<CloudPlaylist>>,
playlists: Option<Vec<DBPlaylist>>,
@ -33,17 +32,18 @@ impl AppScreen for MainScreen {
KeyCode::Left => self.previous_tab(),
KeyCode::Up => self.previous_row(),
KeyCode::Down => self.next_row(),
KeyCode::F(6) => self.download_row(),
KeyCode::F(5) => self.download_row(),
KeyCode::Tab => self.switch_mode(),
KeyCode::F(2) => {
self.sender
KeyCode::F(4) => {
let _ = self
.sender
.send(AppEvent::SwitchScreen(AppState::FileSystem));
}
_ => {}
}
}
fn render(&self, frame: &mut Frame, theme: &Theme) {
fn render(&self, frame: &mut Frame, _theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@ -76,9 +76,11 @@ impl AppScreen for MainScreen {
let status_bar = Paragraph::new(Line::from(vec![
"◄ ► to change tab".bold(),
" | ".dark_gray(),
"<F5> SAVE FS".bold(),
"<TAB> SWITCH PANEL".bold(),
" | ".dark_gray(),
"<F6> DL".bold(),
"<F4> FS MODE".bold(),
" | ".dark_gray(),
"<F5> DOWNLOAD".bold(),
" | ".dark_gray(),
"<F8> DEL".bold(),
" | ".dark_gray(),
@ -97,10 +99,8 @@ impl MainScreen {
pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
MainScreen {
mode: false,
selected_playlist: 0,
selected_song: 0,
max_pls: 0,
max_songs: 0,
pl_table: SmartTable::default(),
song_table: SmartTable::default(),
soundcloud: None,
playlists: None,
selected_tab: 0,
@ -114,42 +114,14 @@ impl MainScreen {
}
}
fn update_max_rows(&mut self) {
self.selected_song = 0;
self.selected_playlist = 0;
self.max_songs = 0;
self.max_pls = match self.selected_tab {
1 => self.soundcloud.as_deref().unwrap_or(&[]).len(),
2 => self.playlists.as_deref().unwrap_or(&[]).len(),
_ => 0,
}
.try_into()
.unwrap();
self.update_max_songs();
}
fn update_max_songs(&mut self) {
if self.max_pls > 0 {
self.max_songs = match self.selected_tab {
1 => self
.soundcloud
.as_deref()
.unwrap()
.get(self.selected_playlist as usize)
.unwrap()
.tracks
.len(),
_ => 0,
}
.try_into()
.unwrap();
self.selected_song = 0;
}
}
fn switch_mode(&mut self) {
self.mode = !self.mode;
self.set_mode(!self.mode);
}
fn set_mode(&mut self, mode: bool) {
self.mode = mode;
self.pl_table.set_checked(!self.mode);
self.song_table.set_checked(self.mode);
}
fn next_tab(&mut self) {
@ -157,31 +129,30 @@ impl MainScreen {
self.selected_tab + 1,
(self.tab_titles.len() - 1).try_into().unwrap(),
);
self.update_max_rows();
self.update_tables();
}
fn previous_tab(&mut self) {
self.selected_tab = std::cmp::max(0, self.selected_tab - 1);
self.update_max_rows();
self.update_tables();
}
fn previous_row(&mut self) {
match self.mode {
true => self.selected_song = std::cmp::max(0, self.selected_song - 1),
true => self.song_table.previous_row(),
false => {
self.selected_playlist = std::cmp::max(0, self.selected_playlist - 1);
self.update_max_songs();
self.pl_table.previous_row();
self.update_songs();
}
}
}
fn next_row(&mut self) {
match self.mode {
true => self.selected_song = std::cmp::min(self.selected_song + 1, self.max_songs - 1),
true => self.song_table.next_row(),
false => {
self.selected_playlist =
std::cmp::min(self.selected_playlist + 1, self.max_pls - 1);
self.update_max_songs();
self.pl_table.next_row();
self.update_songs();
}
}
}
@ -193,7 +164,7 @@ impl MainScreen {
.soundcloud
.as_ref()
.unwrap()
.get(self.selected_playlist as usize)
.get(self.pl_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist));
@ -203,72 +174,165 @@ impl MainScreen {
pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) {
self.soundcloud = Some(pl.collection);
if self.selected_tab == 1 {
self.update_max_rows();
self.update_tables();
}
}
pub fn set_itunes(&mut self, pl: Vec<DBPlaylist>) {
self.playlists = Some(pl);
if self.selected_tab == 2 {
self.update_max_rows();
self.update_tables();
}
}
fn update_tables(&mut self) {
self.set_mode(false);
self.pl_table = SmartTable::new(
["Id", "Title", "Songs Count", "Date", "IS"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
[
Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column
Constraint::Percentage(30),
Constraint::Length(2),
]
.to_vec(),
);
let data = match self.selected_tab {
1 => {
if let Some(sc) = &self.soundcloud {
sc.iter()
.map(|playlist| {
let date: DateTime<Utc> = playlist.created_at.parse().unwrap();
vec![
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(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
2 => {
if let Some(it) = &self.playlists {
it.iter()
.map(|playlist| {
let date = Utc.timestamp_millis_opt(playlist.timestamp as i64).unwrap();
vec![
playlist.id.to_string(),
"".to_string(),
playlist.tracks.len().to_string(),
format!("{}", date.format("%Y-%m-%d %H:%M")),
"YES".to_string(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
_ => {
self.pl_table = SmartTable::default();
Vec::new()
}
};
self.pl_table.set_data(data);
self.pl_table.set_title("Playlists".to_string());
self.update_songs();
}
fn update_songs(&mut self) {
let constraints = [
Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column
Constraint::Length(5),
Constraint::Min(0),
]
.to_vec();
match self.selected_tab {
1 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Duration", "Genre"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.soundcloud {
let s = &pls.get(self.pl_table.selected_row()).unwrap().tracks;
let data = s
.iter()
.map(|track| {
vec![
track.id.to_string(),
track.title.as_deref().unwrap().to_string(),
track
.user
.clone()
.unwrap()
.username
.unwrap_or(track.user.as_ref().unwrap().permalink.clone()),
track.duration.unwrap_or(0).to_string(),
track.genre.as_ref().unwrap_or(&String::new()).to_string(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
self.song_table.set_title(" Songs ".to_string());
}
2 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Bitrate", "Genre"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.playlists {
let s = &pls.get(self.pl_table.selected_row()).unwrap().tracks;
let data = s
.iter()
.map(|track| {
vec![
track.data.unique_id.to_string(),
track.get_title(),
track.get_location(),
track.data.bitrate.to_string(),
track.get_genre(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
self.song_table.set_title(" Songs ".to_string());
}
_ => {
self.song_table = SmartTable::default();
self.set_mode(self.mode);
}
}
}
fn render_tab(&self, frame: &mut Frame, area: Rect) {
let rows = match 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 {
for (i, playlist) in s.iter().enumerate() {
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(),
format!("{}", date.format("%Y-%m-%d %H:%M")),
"NO".to_string(),
]);
if self.selected_playlist == i as i32 {
row = row.style(Style::default().bg(Color::LightBlue).fg(Color::White));
}
v.push(row);
}
}
v
}
2 => {
// local
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.playlists {
for (i, playlist) in s.iter().enumerate() {
let date = Utc.timestamp_millis_opt(playlist.timestamp as i64).unwrap();
let mut row = Row::new(vec![
playlist.id.to_string(),
"".to_string(),
playlist.tracks.len().to_string(),
format!("{}", date.format("%Y-%m-%d %H:%M")),
"YES".to_string(),
]);
if self.selected_playlist == i as i32 {
row = row.style(Style::default().bg(Color::LightBlue).fg(Color::White));
}
v.push(row);
}
}
v
}
_ => Vec::new(),
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@ -277,99 +341,7 @@ impl MainScreen {
])
.split(area);
// Create the table
let 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::Black));
frame.render_widget(table, chunks[0]);
let mut rows = match self.selected_tab {
1 => {
// sc
let mut v = Vec::new();
v.push(
Row::new(vec!["Id", "Title", "Artist", "Duration", "Genre"])
.style(Style::default().fg(Color::Gray)),
);
if let Some(pls) = &self.soundcloud {
let s = &pls.get(self.selected_playlist as usize).unwrap().tracks;
for (i, track) in s.iter().enumerate() {
let mut row = Row::new(vec![
track.id.to_string(),
track.title.as_deref().unwrap().to_string(),
track
.user
.clone()
.unwrap()
.username
.unwrap_or(track.user.as_ref().unwrap().permalink.clone()),
track.duration.unwrap_or(0).to_string(),
track.genre.as_ref().unwrap_or(&String::new()).to_string(),
]);
if self.selected_song == i as i32 {
row = row.style(Style::default().bg(Color::LightBlue).fg(Color::White));
}
v.push(row);
}
}
v
}
2 => {
// local
let mut v = Vec::new();
v.push(
Row::new(vec!["Id", "Title", "Artist", "Bitrate", "Genre"])
.style(Style::default().fg(Color::Gray)),
);
if let Some(pls) = &self.playlists {
let s = &pls.get(self.selected_playlist as usize).unwrap().tracks;
for (i, track) in s.iter().enumerate() {
let mut row = Row::new(vec![
track.data.unique_id.to_string(),
track.get_title(),
track.get_location(),
track.data.bitrate.to_string(),
track.get_genre(),
]);
if self.selected_song == i as i32 {
row = row.style(Style::default().bg(Color::LightBlue).fg(Color::White));
}
v.push(row);
}
}
v
}
_ => Vec::new(),
};
if chunks[1].rows().count() <= self.selected_song as usize {
rows = rows[self.selected_song as usize..].to_vec();
}
// Create the table
let table = Table::new(
rows,
&[
Constraint::Length(3), // ID column
Constraint::Percentage(50), // Playlist name column
Constraint::Percentage(20), // Song count column
Constraint::Length(5),
Constraint::Min(0),
],
)
.block(Block::default().borders(Borders::ALL).title(" Songs "))
.style(Style::default().fg(Color::Black));
frame.render_widget(table, chunks[1]);
self.pl_table.render(frame, chunks[0]);
self.song_table.render(frame, chunks[1]);
}
}

@ -1,13 +1,14 @@
use crate::{screen::AppScreen, theme::Theme};
use color_eyre::owo_colors::OwoColorize;
use ratatui::layout::{Constraint, Direction, Flex, Layout};
use ratatui::widgets::Paragraph;
use ratatui::{
style::{Style, Stylize},
symbols::border,
text::{Line, Text},
widgets::{Block, Paragraph},
text::Line,
Frame,
};
use crate::{screen::AppScreen, theme::Theme};
use throbber_widgets_tui::{ThrobberState, BOX_DRAWING};
use tui_big_text::{BigText, PixelSize};
#[derive(Debug, Clone, Default)]
pub struct WaitScreen {}
@ -16,21 +17,37 @@ impl AppScreen for WaitScreen {
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {}
fn render(&self, frame: &mut Frame, theme: &Theme) {
let title = Line::from(" Lyrica ".bold());
let instructions = Line::from(vec![" Quit ".into(), "<Q> ".red().bold()]);
let block = Block::bordered()
.title(title.centered())
.title_bottom(instructions.centered())
.border_set(border::ROUNDED);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(33); 3])
.split(frame.area());
let counter_text = Text::from(vec![Line::from(vec!["Searching for iPod...".into()])]);
let simple = throbber_widgets_tui::Throbber::default()
.label("Searching for your iPod")
.throbber_set(BOX_DRAWING);
let par = Paragraph::new(counter_text)
.style(Style::new().bg(theme.background()))
let bottom =
Paragraph::new(vec![Line::from(vec![" Quit ".into(), "<Q> ".red().bold()])]).centered();
let bottom_l =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(chunks[2]);
let [throbber_l] = Layout::horizontal([Constraint::Length(
simple.to_line(&ThrobberState::default()).width() as u16,
)])
.flex(Flex::Center)
.areas(bottom_l[0]);
frame.render_widget(simple, throbber_l);
frame.render_widget(bottom, bottom_l[1]);
let title = BigText::builder()
.pixel_size(PixelSize::Full)
.style(Style::new().blue())
.lines(vec!["Lyrica".light_blue().into()])
.centered()
.block(block);
.build();
frame.render_widget(par, frame.area());
frame.render_widget(title, chunks[1]);
}
fn as_any(&mut self) -> &mut dyn std::any::Any {