Compare commits

..

No commits in common. "main" and "cli" have entirely different histories.
main ... cli

15 changed files with 416 additions and 3719 deletions

1010
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -6,32 +6,16 @@ license = "AGPLv3"
authors = ["Michael Wain <alterwain@protonmail.com>"]
[dependencies]
chrono = "0.4.39"
rusb = "0.9.4"
dirs = "6.0.0"
toml = "0.8.20"
serde = "1.0.217"
serde_json = "1.0"
serde-xml-rs = "0.6.0"
regex = "1.11.1"
ratatui = { version = "0.29.0", features = ["all-widgets"] }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["event-stream"] }
futures = "0.3"
crossterm = "0.28.1"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7.12", features = ["codec"] }
soundcloud = { version = "0.1.11", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
youtube-api = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/youtube_api.git" }
itunesdb = { version = "0.1.99", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }
rand = "0.8.5"
tui-big-text = "0.7.1"
throbber-widgets-tui = "0.8.0"
audiotags = "0.5.0"
image = "0.25.5"
twox-hash = "2.1.0"
[profile.release]
strip = true
opt-level = "s"
lto = true
codegen-units = 1
strum = { version = "0.27", features = ["derive"] }
soundcloud = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
itunesdb = { version = "0.1.1", git = "https://gitea.awain.net/alterwain/ITunesDB.git" }

@ -1,47 +0,0 @@
<img align="left" width="100" height="100" src="https://w0n.zip/file/dRDDwa">
**Lyrica**
Lightweight iPod manager, batteries included.
#
<div align="center">
<img src="https://w0n.zip/file/dRgjRe"/>
<p>
<small>Screenshot from MacOS Big Sur terminal</small>
</p>
</div>
## Features
- **Basic operations**: Load songs from filesystem to ipod. Manually delete anything if needed.
- **Streamings**: Download playlists from Soundcloud, Youtube to your iPod.
- **No iTunes**: Finally you can get rid of iTunes or Apple Music. Marvelous!
- **Playlists**: Create/remove/edit playlists on your ipod
## Requirements
- iPod classic
- Mac OS
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) A feature-rich command-line audio/video downloader.
## Install / Update
To install or update Lyrica simply run this command in your Mac terminal:
```bash
curl -sSf https://w0n.zip/raw/egM23b | sh
```
### Uninstall
To uninstall Lyrica (but why?) you need to run this command in your terminal:
```bash
rm /usr/local/bin/lyrica
```
## Usage
Just type lyrica in your terminal from anywhere

@ -1,89 +0,0 @@
pub mod table {
use ratatui::layout::{Constraint, Rect};
use ratatui::prelude::{Color, Style};
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>,
selected_row: i32,
title: String,
}
impl SmartTable {
pub fn new(header: Vec<String>, constraints: Vec<Constraint>) -> Self {
Self {
is_checked: true,
header,
data: Vec::new(),
constraints,
selected_row: 0,
title: String::new(),
}
}
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;
}
pub fn set_title(&mut self, title: String) {
self.title = title;
}
pub fn previous_row(&mut self) {
self.selected_row = (self.selected_row - 1).max(0);
}
pub fn next_row(&mut self) {
self.selected_row = (self.selected_row + 1).min(self.data.len() as i32 - 1);
}
pub fn selected_row(&self) -> usize {
self.selected_row as usize
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let mut v = vec![Row::new(self.header.clone()).style(Style::default().fg(Color::Gray))];
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(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
&& self.selected_row - ((area.rows().count() - 4) as i32) >= 0
{
v = v[(self.selected_row as usize - (area.rows().count() - 4))..].to_vec();
}
let table = Table::new(v, self.constraints.clone())
.block(
Block::default()
.borders(Borders::ALL)
.title(self.title.as_ref()),
)
.style(Style::default().fg(Color::Black));
frame.render_widget(table, area);
}
}
}

@ -1,51 +1,19 @@
use std::path::PathBuf;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
pub fn get_configs_dir() -> PathBuf {
let mut p = dirs::home_dir().unwrap();
p.push(".lyrica");
p
}
pub fn get_temp_dl_dir() -> PathBuf {
let mut p = get_configs_dir();
p.push("tmp");
p
}
pub fn clear_temp_dl_dir() {
let path = get_temp_dl_dir();
let _ = std::fs::remove_dir_all(path);
}
pub fn get_config_path() -> PathBuf {
let mut p = get_configs_dir();
p.push("config");
p.set_extension("toml");
p
}
pub fn get_temp_itunesdb() -> PathBuf {
let mut p = get_configs_dir();
p.push("idb");
p
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize)]
pub struct YouTubeConfiguration {
pub user_id: String,
pub user_id: u64
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize)]
pub struct SoundCloudConfiguration {
pub user_id: u64,
pub user_id: u64
}
#[derive(Debug, Deserialize, Serialize, Default)]
#[derive(Debug, Deserialize)]
pub struct LyricaConfiguration {
soundcloud: SoundCloudConfiguration,
youtube: YouTubeConfiguration,
youtube: YouTubeConfiguration
}
impl LyricaConfiguration {
@ -56,4 +24,4 @@ impl LyricaConfiguration {
pub fn get_youtube(&self) -> &YouTubeConfiguration {
&self.youtube
}
}
}

@ -1,260 +0,0 @@
use ratatui::style::Color;
use regex::Regex;
use serde::Deserialize;
use std::{io, path::PathBuf, process::Stdio};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::Command,
sync::mpsc::Sender,
};
use crate::sync::AppEvent;
#[derive(Debug, Deserialize)]
pub struct DownloadProgress {
pub progress_percentage: String,
pub progress_total: String,
pub eta: String,
}
pub async fn download_track_from_youtube(
track_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"bestaudio",
"-x",
"--audio-format",
"mp3",
"--audio-quality",
"0",
"-o",
"%(id)s.%(ext)s",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"--write-thumbnail",
&*["https://youtube.com/watch?v=", track_url].concat()
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender
.send(AppEvent::OverallProgress((0, 1, Color::Green)))
.await;
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
let _ = sender
.send(AppEvent::OverallProgress((1, 1, Color::Green)))
.await;
Ok(())
}
pub async fn download_track_from_soundcloud(
track_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"mp3",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"-o",
"%(id)i.%(ext)s",
"--write-thumbnail",
track_url
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender
.send(AppEvent::OverallProgress((0, 1, Color::Green)))
.await;
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
let _ = sender
.send(AppEvent::OverallProgress((1, 1, Color::Green)))
.await;
Ok(())
}
pub async fn download_from_youtube(
playlist_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
let dl_rx: Regex = Regex::new(r"\[download\] Downloading item \d+ of \d+").unwrap();
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"bestaudio",
"-x",
"--audio-format",
"mp3",
"--audio-quality",
"0",
"-o",
"%(id)s.%(ext)s",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"--write-thumbnail",
&*["https://youtube.com", playlist_url].concat()
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
match dl_rx.find(&line) {
Some(m) => {
let mut s = m.as_str();
s = s.split("Downloading item ").last().unwrap();
let s: Vec<&str> = s.split(' ').collect();
let cur = s.first().unwrap().trim().parse().unwrap();
let max = s.last().unwrap().trim().parse().unwrap();
let _ = sender
.send(AppEvent::OverallProgress((cur, max, Color::Green)))
.await;
}
None => {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
}
}
Ok(())
}
pub async fn download_from_soundcloud(
playlist_url: &str,
download_dir: &PathBuf,
sender: Sender<AppEvent>,
) -> io::Result<()> {
let _ = sender
.send(AppEvent::SwitchScreen(crate::AppState::LoadingScreen))
.await;
let dl_rx: Regex = Regex::new(r"\[download\] Downloading item \d+ of \d+").unwrap();
if download_dir.exists() {
let _ = std::fs::remove_dir_all(download_dir);
}
let _ = std::fs::create_dir_all(download_dir);
let args = &[
"-f",
"mp3",
"--ignore-errors",
"--newline",
"--progress-template",
"{\"progress_percentage\":\"%(progress._percent_str)s\",\"progress_total\":\"%(progress._total_bytes_str)s\",\"speed\":\"%(progress._speed_str)s\",\"eta\":\"%(progress._eta_str)s\"}",
"-o",
"%(id)i.%(ext)s",
"--write-thumbnail",
playlist_url
];
let mut command = Command::new("yt-dlp");
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::null());
command.current_dir(download_dir);
let mut child = command.spawn()?;
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
match dl_rx.find(&line) {
Some(m) => {
let mut s = m.as_str();
s = s.split("Downloading item ").last().unwrap();
let s: Vec<&str> = s.split(' ').collect();
let cur = s.first().unwrap().trim().parse().unwrap();
let max = s.last().unwrap().trim().parse().unwrap();
let _ = sender
.send(AppEvent::OverallProgress((cur, max, Color::Green)))
.await;
}
None => {
if line.starts_with("{") {
let progress: DownloadProgress = serde_json::from_str(&line).unwrap();
let _ = sender.send(AppEvent::CurrentProgress(progress)).await;
}
}
}
}
Ok(())
}

@ -1,260 +0,0 @@
use crate::component::table::SmartTable;
use crate::sync::AppEvent;
use crate::{screen::AppScreen, AppState};
use chrono::{DateTime, Utc};
use crossterm::event::KeyCode;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Line, Stylize};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use std::cmp::Ordering;
use std::ffi::OsStr;
use std::fs::DirEntry;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
use tokio::sync::mpsc::UnboundedSender;
pub struct FileSystem {
files: Vec<DirEntry>,
current_path: PathBuf,
table: SmartTable,
sender: UnboundedSender<AppEvent>,
}
fn check_extension_compatibility(ext: &str) -> bool {
matches!(
ext.to_lowercase().as_str(),
"mp3" | "m4a" | "wav" | "aiff" | "aif"
)
}
fn get_extension_from_filename(file_name: Option<&OsStr>) -> String {
if let Some(fname) = file_name {
let file_name = fname.to_str().unwrap();
let index = file_name
.chars()
.enumerate()
.filter(|(_i, c)| *c == '.')
.map(|(i, _c)| i)
.last();
if let Some(index) = index {
let extension: String = file_name.chars().skip(index).collect();
return extension;
}
}
"none".to_string()
}
fn list_files_recursively(p: PathBuf) -> Vec<PathBuf> {
let mut files = Vec::new();
let paths = std::fs::read_dir(p).unwrap();
for path in paths {
if path.is_err() {
continue;
}
let a = path.unwrap().path();
if a.is_file()
&& check_extension_compatibility(
a.extension()
.map_or(&get_extension_from_filename(a.file_name()), |s| {
s.to_str().unwrap()
}),
)
{
files.push(a.clone());
}
if a.is_dir() {
files.append(&mut list_files_recursively(a));
}
}
files
}
impl AppScreen for FileSystem {
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
match key_event.code {
KeyCode::Up => self.table.previous_row(),
KeyCode::Down => self.table.next_row(),
KeyCode::F(4) => {
let _ = self
.sender
.send(AppEvent::SwitchScreen(AppState::MainScreen));
}
KeyCode::F(5) => self.download_as_is(),
KeyCode::F(6) => self.download_as_playlist(),
KeyCode::Enter => self.enter_directory(),
_ => {}
}
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(frame.area());
self.render_main(frame, chunks[0]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec![
"<F4> SWITCH TO NORMAL".bold(),
" | ".dark_gray(),
"<F5> SAVE AS IS".bold(),
" | ".dark_gray(),
"<F6> SAVE AS PLAYLIST".bold(),
" | ".dark_gray(),
"<Q> QUIT".bold(),
]))
.centered();
frame.render_widget(status_bar, chunks[1]); // Render into third chunk
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl FileSystem {
pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
let table = SmartTable::new(
["Name", "Type", "Size", "Modified"]
.iter_mut()
.map(|s| s.to_string())
.collect(),
vec![
Constraint::Percentage(50),
Constraint::Length(5),
Constraint::Percentage(20),
Constraint::Percentage(30),
],
);
let mut a = Self {
table,
sender,
files: Vec::new(),
current_path: dirs::document_dir().unwrap(),
};
a.get_path(dirs::document_dir().unwrap());
a
}
fn get_path(&mut self, p: PathBuf) {
self.current_path = p.clone();
let paths = std::fs::read_dir(&p).unwrap();
let mut dir = paths
.filter_map(|res| res.ok())
.filter(|p| {
p.path().extension().map_or(false, |s| {
check_extension_compatibility(s.to_str().unwrap_or("none"))
}) || p.path().is_dir()
})
.collect::<Vec<DirEntry>>();
dir.sort_by(|a, b| {
if a.file_type().unwrap().is_dir() == b.file_type().unwrap().is_dir() {
let af = a.file_name();
let bf = b.file_name();
let ac = af.to_str().unwrap_or("a");
let bc = bf.to_str().unwrap_or("a");
return ac.cmp(bc);
}
if a.file_type().unwrap().is_dir() {
Ordering::Less
} else {
Ordering::Greater
}
});
let data = dir
.iter()
.map(|entry| {
let datetime: DateTime<Utc> = entry.metadata().unwrap().modified().unwrap().into();
let datetime = datetime.format("%d/%m/%Y %T").to_string();
let size = entry.metadata().unwrap().size().to_string();
let file_type = if entry.file_type().unwrap().is_file() {
"FILE"
} else {
"DIR"
}
.to_string();
vec![
entry.file_name().to_str().unwrap().to_string(),
file_type,
size,
datetime,
]
})
.collect::<Vec<Vec<String>>>();
let data = [vec![vec![String::from("..")]], data].concat();
self.files = dir;
self.table.set_data(data);
self.table
.set_title(p.iter().last().unwrap().to_str().unwrap().to_string());
}
fn download_as_is(&self) {
if let 1.. = self.table.selected_row() {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if entry.path().is_dir() {
let files = list_files_recursively(entry.path());
let _ = self.sender.send(AppEvent::LoadFromFSVec(files));
} else {
let _ = self.sender.send(AppEvent::LoadFromFS(entry.path()));
}
}
}
fn download_as_playlist(&self) {
if let 1.. = self.table.selected_row() {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if entry.path().is_dir() {
let files = list_files_recursively(entry.path());
let _ = self.sender.send(AppEvent::LoadFromFSPL((
files,
entry
.path()
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
)));
}
}
}
fn move_up(&mut self) {
let p = self.current_path.parent();
if p.is_none() {
return;
}
let p: PathBuf = p.unwrap().to_path_buf();
self.get_path(p);
}
fn enter_directory(&mut self) {
match self.table.selected_row() {
0 => self.move_up(),
_ => {
let entry = self.files.get(self.table.selected_row() - 1).unwrap();
if !entry.path().is_dir() {
return;
}
self.get_path(entry.path());
}
}
}
fn render_main(&self, frame: &mut Frame, area: Rect) {
self.table.render(frame, area);
}
}

@ -1,128 +0,0 @@
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Borders, Gauge, Paragraph},
Frame,
};
use crate::{dlp::DownloadProgress, screen::AppScreen};
#[derive(Default)]
pub struct LoadingScreen {
pub progress: Option<(u32, u32, ratatui::style::Color)>,
pub artwork_progress: Option<(u32, u32)>,
pub s_progress: Option<DownloadProgress>,
}
impl AppScreen for LoadingScreen {
fn handle_key_event(&mut self, _key_event: crossterm::event::KeyEvent) {}
fn render(&self, frame: &mut ratatui::Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content area
Constraint::Length(1), // Status bar
])
.split(frame.area());
self.render_progress(frame, chunks[0]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec!["<Q> QUIT".bold()])).centered();
frame.render_widget(status_bar, chunks[1]); // Render into third chunk
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl LoadingScreen {
fn render_progress(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), // Main content
Constraint::Length(6), // Progress bar
Constraint::Length(6), // Progress bar
])
.split(area);
let main_content = Paragraph::new("Please wait").block(
Block::default()
.borders(Borders::ALL)
.title("Downloading has started!"),
);
frame.render_widget(main_content, chunks[0]);
if self.progress.is_some() {
self.render_overall(frame, chunks[1]);
}
if self.artwork_progress.is_some() {
self.render_artwork_progress(frame, chunks[2]);
} else if self.s_progress.is_some() {
self.render_current(frame, chunks[2]);
}
}
fn render_artwork_progress(&self, frame: &mut Frame, area: Rect) {
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Generating album covers "),
)
.gauge_style(Style::default().fg(Color::LightBlue))
.ratio(
self.artwork_progress.unwrap().0 as f64 / self.artwork_progress.unwrap().1 as f64,
)
.label("Generating album covers...");
frame.render_widget(gauge, area);
}
fn render_current(&self, frame: &mut Frame, area: Rect) {
let s: String = self
.s_progress
.as_ref()
.unwrap()
.progress_percentage
.chars()
.filter(|c| c.is_ascii_digit() || *c == '.')
.collect();
let ratio: f64 = s.parse::<f64>().unwrap_or(0.0);
let gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(format!(
" Downloading Item (ETA: {}) ",
self.s_progress.as_ref().unwrap().eta
)))
.gauge_style(Style::default().fg(Color::Green))
.ratio(ratio / 100.0)
.label(self.s_progress.as_ref().unwrap().progress_total.to_string());
frame.render_widget(gauge, area);
}
fn render_overall(&self, frame: &mut Frame, area: Rect) {
let gauge = Gauge::default()
.block(
Block::default()
.borders(Borders::ALL)
.title(" Downloading Playlist "),
)
.gauge_style(Style::default().fg(self.progress.unwrap().2))
.ratio(self.progress.unwrap().0 as f64 / self.progress.unwrap().1 as f64)
.label(format!(
"{:}/{:}",
self.progress.unwrap().0,
self.progress.unwrap().1
));
frame.render_widget(gauge, area);
}
}

@ -1,50 +1,103 @@
use crate::file_system::FileSystem;
use std::{error::Error, io, path::{Path, PathBuf}};
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 futures::StreamExt;
use loading_screen::LoadingScreen;
use main_screen::MainScreen;
use ratatui::{
prelude::{Backend, CrosstermBackend},
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};
use config::LyricaConfiguration;
use crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}};
use ratatui::{buffer::Buffer, layout::{Layout, Rect}, prelude::{Backend, CrosstermBackend}, style::{Color, Stylize}, symbols::border, text::{Line, Text}, widgets::{Block, Paragraph, Tabs, Widget}, DefaultTerminal, Frame, Terminal};
use screen::MainScreen;
use soundcloud::sobjects::CloudPlaylists;
use strum::IntoEnumIterator;
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}};
use tokio_util::sync::CancellationToken;
use wait_screen::WaitScreen;
use itunesdb::xobjects::XDatabase;
use ratatui::prelude::Constraint::{Length, Min};
mod component;
mod config;
mod dlp;
mod file_system;
mod loading_screen;
mod main_screen;
mod screen;
mod sync;
mod util;
mod wait_screen;
mod config;
mod tabs;
mod screen;
#[derive(Eq, Hash, PartialEq)]
enum AppState {
IPodWait,
MainScreen,
LoadingScreen,
FileSystem,
fn get_configs_dir() -> PathBuf {
let mut p = dirs::home_dir().unwrap();
p.push(".lyrica");
p
}
#[derive(Debug, Clone)]
enum AppState {
IPodWait,
MainScreen(crate::screen::MainScreen)
}
enum AppEvent {
SearchIPod,
IPodFound(String),
IPodNotFound,
ParseItunes(String),
ITunesParsed(XDatabase),
SoundcloudGot(CloudPlaylists)
}
fn initialize_async_service(sender: Sender<AppEvent>, receiver: UnboundedReceiver<AppEvent>, token: CancellationToken) {
tokio::spawn(async move {
let mut receiver = receiver;
loop {
tokio::select! {
_ = token.cancelled() => { return; }
r = receiver.recv() => {
if let Some(request) = r {
match request {
AppEvent::SearchIPod => {
/*if let Some(p) = util::search_ipod() {
let _ = sender.send(AppEvent::IPodFound(p)).await;
} else {
let _ = sender.send(AppEvent::IPodNotFound).await;
}*/
let _ = sender.send(AppEvent::IPodFound("D:\\Documents\\RustroverProjects\\itunesdb\\ITunesDB\\two_tracks".to_string())).await;
},
AppEvent::ParseItunes(path) => {
// todo: parse itunes
let _ = std::fs::create_dir_all(get_configs_dir());
let mut cd = get_configs_dir();
cd.push("idb");
let mut p: PathBuf = Path::new(&path).into();
// p.push("iPod_Control");
// p.push("iTunes");
// p.set_file_name("iTunesDB");
let _ = std::fs::copy(p, &cd);
let mut file = File::open(cd).await.unwrap();
let mut contents = vec![];
file.read_to_end(&mut contents).await.unwrap();
let xdb = itunesdb::deserializer::parse_bytes(&contents);
let _ = sender.send(AppEvent::ITunesParsed(xdb)).await;
let mut p = get_configs_dir();
p.push("config");
p.set_extension(".toml");
if !p.exists() { return; }
let mut file = File::open(p).await.unwrap();
let mut content = String::new();
file.read_to_string(&mut content).await.unwrap();
let config: LyricaConfiguration = toml::from_str(&content).unwrap();
let app_version = soundcloud::get_app().await.unwrap().unwrap();
let client_id = soundcloud::get_client_id().await.unwrap().unwrap();
let playlists = soundcloud::get_playlists(config.get_soundcloud().user_id, client_id, app_version).await.unwrap();
let _ = sender.send(AppEvent::SoundcloudGot(playlists)).await;
},
_ => {}
}
}
}
}
}
});
}
#[derive(Debug)]
pub struct App {
state: AppState,
screens: HashMap<AppState, Box<dyn AppScreen>>,
receiver: Receiver<AppEvent>,
sender: UnboundedSender<AppEvent>,
token: CancellationToken,
@ -52,133 +105,140 @@ pub struct App {
impl Default for App {
fn default() -> Self {
let (tx, rx) = mpsc::channel(10);
let (jx, jr) = mpsc::unbounded_channel();
let (tx, mut rx) = mpsc::channel(1);
let (jx, mut jr) = mpsc::unbounded_channel();
let token = CancellationToken::new();
sync::initialize_async_service(tx, jr, token.clone());
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())));
screens.insert(AppState::LoadingScreen, Box::new(LoadingScreen::default()));
screens.insert(AppState::FileSystem, Box::new(FileSystem::new(jx.clone())));
Self {
receiver: rx,
sender: jx,
token,
state: AppState::IPodWait,
screens,
}
Self { state: AppState::IPodWait, receiver: rx, sender: jx, token }
}
}
impl App {
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
let mut reader = EventStream::new();
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
while !self.token.is_cancelled() {
let _ = self.handle_events(&mut reader).await;
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
self.screens.get(&self.state).unwrap().render(frame);
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self.state.clone(), frame.area());
}
async fn handle_events(&mut self, reader: &mut EventStream) {
tokio::select! {
Some(Ok(event)) = reader.next() => {
match event {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event);
}
_ => {}
}
},
Some(event) = self.receiver.recv() => {
match event {
AppEvent::IPodNotFound => {
let _ = self.sender.send(AppEvent::SearchIPod);
},
AppEvent::ITunesParsed(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_itunes(playlists);
},
AppEvent::SoundcloudGot(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_soundcloud_playlists(playlists);
},
AppEvent::YoutubeGot(playlists) => {
let screen: &mut MainScreen = self.get_screen(&AppState::MainScreen);
screen.set_youtube_playlists(playlists);
},
AppEvent::OverallProgress((c, max, color)) => {
self.state = AppState::LoadingScreen;
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.progress = Some((c, max, color));
screen.artwork_progress = None;
},
AppEvent::CurrentProgress(progress) => {
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.artwork_progress = None;
screen.s_progress = Some(progress);
},
AppEvent::ArtworkProgress((c, max)) => {
let screen: &mut LoadingScreen = self.get_screen(&AppState::LoadingScreen);
screen.artwork_progress = Some((c, max));
screen.s_progress = None;
},
AppEvent::SwitchScreen(screen) => {
self.state = screen;
}
_ => {}
}
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ = tokio::time::sleep(Duration::from_millis(200)) => {
_ => {}
};
if let Ok(event) = self.receiver.try_recv() {
match event {
AppEvent::IPodFound(path) => {
self.state = AppState::MainScreen(MainScreen::new());
let _ = self.sender.send(AppEvent::ParseItunes(path));
},
AppEvent::IPodNotFound => {
let _ = self.sender.send(AppEvent::SearchIPod);
},
AppEvent::ITunesParsed(xdb) => {
},
AppEvent::SoundcloudGot(playlists) => {
if let AppState::MainScreen(screen) = &self.state {
let mut screen = screen.clone();
screen.soundcloud = Some(playlists);
self.state = AppState::MainScreen(screen);
}
}
_ => {}
}
}
Ok(())
}
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()
if let AppState::MainScreen(screen) = &self.state {
let mut screen = screen.clone();
screen.handle_key_event(key_event);
self.state = AppState::MainScreen(screen);
}
match key_event.code {
KeyCode::Char('q') => self.exit(),
_ => {}
}
}
fn exit(&mut self) {
self.token.cancel();
}
}
fn get_screen<T>(&mut self, state: &AppState) -> &mut T
where
T: 'static + AppScreen,
{
let a = self.screens.get_mut(state).unwrap();
a.as_any().downcast_mut::<T>().unwrap()
impl AppState {
fn render_main_screen(area: Rect, buf: &mut Buffer, screen: &mut MainScreen) {
let vertical = Layout::vertical([Length(1), Min(0), Length(1)]);
let [header_area, inner_area, footer_area] = vertical.areas(area);
let horizontal = Layout::horizontal([Min(0), Length(7)]);
let [tabs_area, title_area] = horizontal.areas(header_area);
MainScreen::render_title(title_area, buf);
screen.render_tabs(tabs_area, buf);
screen.selected_tab.render(inner_area, buf);
MainScreen::render_footer(footer_area, buf);
}
fn render_waiting_screen(area: Rect, buf: &mut Buffer) {
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 counter_text = Text::from(
vec![
Line::from(
vec![
"Searching for iPod...".into()
]
)
]
);
Paragraph::new(counter_text)
.centered()
.block(block)
.render(area, buf);
}
}
impl Widget for AppState {
fn render(self, area: Rect, buf: &mut Buffer) {
match self {
AppState::IPodWait => AppState::render_waiting_screen(area, buf),
AppState::MainScreen(mut s) => AppState::render_main_screen(area, buf, &mut s),
_ => {}
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
enable_raw_mode()?;
let mut stderr = io::stdout();
let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stderr);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let mut app = App::default();
let _ = app.run(&mut terminal).await;
app.run(&mut terminal);
// restore terminal
disable_raw_mode()?;
@ -190,4 +250,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
terminal.show_cursor()?;
Ok(())
}
}

@ -1,525 +0,0 @@
use chrono::{DateTime, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
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, YTPlaylist};
use crate::{screen::AppScreen, sync::AppEvent, AppState};
pub struct MainScreen {
mode: bool,
selected_tab: i8,
pl_table: SmartTable,
song_table: SmartTable,
tab_titles: Vec<String>,
youtube: Option<Vec<YTPlaylist>>,
soundcloud: Option<Vec<CloudPlaylist>>,
playlists: Option<Vec<DBPlaylist>>,
sender: UnboundedSender<AppEvent>,
}
impl AppScreen for MainScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Right => self.next_tab(),
KeyCode::Left => self.previous_tab(),
KeyCode::Up => self.previous_row(),
KeyCode::Down => self.next_row(),
KeyCode::F(5) => self.download_row(),
KeyCode::F(8) => self.remove_row(),
KeyCode::F(9) => self.remove_completely(),
KeyCode::Tab => self.switch_mode(),
KeyCode::F(4) => {
let _ = self
.sender
.send(AppEvent::SwitchScreen(AppState::FileSystem));
}
_ => {}
}
}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
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::LightBlue)
.add_modifier(Modifier::BOLD),
)
.select(self.selected_tab as usize)
.style(Style::default().fg(Color::Black));
frame.render_widget(tabs, chunks[0]);
self.render_tab(frame, chunks[1]);
// Render Status Bar
let status_bar = Paragraph::new(Line::from(vec![
"<TAB> SWITCH PANEL".bold(),
" | ".dark_gray(),
"<F4> FS MODE".bold(),
" | ".dark_gray(),
"<F5> DOWNLOAD".bold(),
" | ".dark_gray(),
"<F8> DEL".bold(),
" | ".dark_gray(),
"<F9> DEL REC".bold(),
" | ".dark_gray(),
"<Q> QUIT".bold(),
]))
.centered();
frame.render_widget(status_bar, chunks[2]);
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}
impl MainScreen {
pub fn new(sender: UnboundedSender<AppEvent>) -> Self {
MainScreen {
mode: false,
pl_table: SmartTable::default(),
song_table: SmartTable::default(),
soundcloud: None,
youtube: None,
playlists: None,
selected_tab: 0,
tab_titles: vec![
"YouTube".to_string(),
"SoundCloud".to_string(),
"iPod".to_string(),
"Settings".to_string(),
],
sender,
}
}
fn switch_mode(&mut self) {
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) {
self.selected_tab = std::cmp::min(
self.selected_tab + 1,
(self.tab_titles.len() - 1).try_into().unwrap(),
);
self.update_tables();
}
fn previous_tab(&mut self) {
self.selected_tab = std::cmp::max(0, self.selected_tab - 1);
self.update_tables();
}
fn previous_row(&mut self) {
match self.mode {
true => self.song_table.previous_row(),
false => {
self.pl_table.previous_row();
self.update_songs();
}
}
}
fn next_row(&mut self) {
match self.mode {
true => self.song_table.next_row(),
false => {
self.pl_table.next_row();
self.update_songs();
}
}
}
fn remove_row(&mut self) {
if self.selected_tab != 2 {
return;
}
let pl_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.id;
match self.mode {
false => {
let _ = self.sender.send(AppEvent::RemovePlaylist((pl_id, false)));
}
true => {
let track_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.data
.unique_id;
let _ = self
.sender
.send(AppEvent::RemoveTrackFromPlaylist((track_id, pl_id)));
}
}
}
fn remove_completely(&mut self) {
if self.selected_tab != 2 {
return;
}
match self.mode {
false => {
let pl_id = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.id;
let _ = self.sender.send(AppEvent::RemovePlaylist((pl_id, true)));
}
true => {
let track = self
.playlists
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self
.sender
.send(AppEvent::RemoveTrack(track.data.unique_id));
}
}
}
fn download_row(&mut self) {
match self.selected_tab {
0 => {
// YT
match self.mode {
false => {
let playlist = self
.youtube
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadYTPlaylist(playlist));
}
true => {
let track = self
.youtube
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.videos
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadYTTrack(track));
}
}
}
1 => {
// SC
match self.mode {
false => {
let playlist = self
.soundcloud
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadPlaylist(playlist));
}
true => {
let track = self
.soundcloud
.as_ref()
.unwrap()
.get(self.pl_table.selected_row())
.unwrap()
.tracks
.get(self.song_table.selected_row())
.unwrap()
.clone();
let _ = self.sender.send(AppEvent::DownloadTrack(track));
}
}
}
_ => {}
}
}
pub fn set_youtube_playlists(&mut self, pls: Vec<YTPlaylist>) {
self.youtube = Some(pls);
if self.selected_tab == 0 {
self.update_tables();
}
}
pub fn set_soundcloud_playlists(&mut self, pl: CloudPlaylists) {
self.soundcloud = Some(pl.collection);
if self.selected_tab == 1 {
self.update_tables();
}
}
pub fn set_itunes(&mut self, pl: Vec<DBPlaylist>) {
self.playlists = Some(pl);
if self.selected_tab == 2 {
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 {
0 => {
if let Some(yt) = &self.youtube {
yt.iter()
.map(|playlist| {
vec![
0.to_string(),
playlist.title.clone(),
[playlist.videos.len().to_string(), " songs".to_string()].concat(),
String::new(),
"NO".to_string(),
]
})
.collect::<Vec<Vec<String>>>()
} else {
Vec::new()
}
}
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(),
playlist.title.clone(),
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 {
0 => {
self.song_table = SmartTable::new(
["Id", "Title", "Artist", "Duration", ""]
.iter_mut()
.map(|s| s.to_string())
.collect(),
constraints,
);
self.set_mode(self.mode);
if let Some(pls) = &self.youtube {
if let Some(ypl) = &pls.get(self.pl_table.selected_row()) {
let y = ypl.videos.clone();
let data = y
.iter()
.map(|video| {
vec![
video.videoId.clone(),
video.title.clone(),
video.publisher.clone(),
video.lengthSeconds.to_string(),
String::new(),
]
})
.collect::<Vec<Vec<String>>>();
self.song_table.set_data(data);
}
}
self.song_table.set_title(" Songs ".to_string());
}
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_artist(),
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 chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30), // Playlists
Constraint::Min(0), // Tracks
])
.split(area);
self.pl_table.render(frame, chunks[0]);
self.song_table.render(frame, chunks[1]);
}
}

@ -1,12 +1,56 @@
use std::any::Any;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Stylize}, text::Line, widgets::{Tabs, Widget}};
use soundcloud::sobjects::CloudPlaylists;
use strum::IntoEnumIterator;
use crossterm::event::KeyEvent;
use ratatui::Frame;
use crate::tabs::SelectedTab;
pub trait AppScreen {
fn handle_key_event(&mut self, key_event: KeyEvent);
fn render(&self, frame: &mut Frame);
fn as_any(&mut self) -> &mut dyn Any;
#[derive(Debug, Clone)]
pub struct MainScreen {
pub selected_tab: SelectedTab,
pub soundcloud: Option<CloudPlaylists>
}
impl MainScreen {
pub fn new() -> Self {
MainScreen { selected_tab: SelectedTab::Playlists, soundcloud: None }
}
pub fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('l') | KeyCode::Right => self.next_tab(),
KeyCode::Char('h') | KeyCode::Left => self.previous_tab(),
_ => {}
}
}
pub fn render_title(area: Rect, buf: &mut Buffer) {
"Lyrica".bold().render(area, buf);
}
pub fn render_footer(area: Rect, buf: &mut Buffer) {
Line::raw("◄ ► to change tab | <Q> to quit")
.centered()
.render(area, buf);
}
pub fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
let titles = SelectedTab::iter().map(SelectedTab::title);
let highlight_style = (Color::default(), self.selected_tab.palette().c700);
let selected_tab_index = self.selected_tab.to_usize();
Tabs::new(titles)
.highlight_style(highlight_style)
.select(selected_tab_index)
.padding("", "")
.divider(" ")
.render(area, buf);
}
fn next_tab(&mut self) {
self.selected_tab = self.selected_tab.next();
}
fn previous_tab(&mut self) {
self.selected_tab = self.selected_tab.previous();
}
}

File diff suppressed because it is too large Load Diff

97
src/tabs.rs Normal file

@ -0,0 +1,97 @@
use ratatui::{buffer::Buffer, layout::Rect, style::{palette::tailwind, Stylize}, symbols, text::Line, widgets::{Block, Padding, Paragraph, Widget}};
use soundcloud::sobjects::CloudPlaylists;
use strum::{AsRefStr, Display, EnumIter, FromRepr, IntoEnumIterator};
use crate::screen::MainScreen;
#[derive(Debug, Default, Clone, Display, FromRepr, EnumIter, AsRefStr)]
pub enum SelectedTab {
#[default]
#[strum(to_string = "Playlists")]
Playlists,
#[strum(to_string = "Albums")]
Albums,
#[strum(to_string = "Soundcloud")]
Soundcloud(Option<CloudPlaylists>),
#[strum(to_string = "Youtube")]
Youtube,
}
impl Widget for SelectedTab {
fn render(self, area: Rect, buf: &mut Buffer) {
let block = self.block();
match self {
Self::Albums => self.render_albums(area, buf),
Self::Playlists => self.render_playlists(area, buf),
Self::Soundcloud(playlists) => SelectedTab::render_soundcloud(block,area, buf, playlists),
Self::Youtube => self.render_youtube(area, buf),
}
}
}
impl SelectedTab {
/// Return tab's name as a styled `Line`
pub fn title(self) -> Line<'static> {
format!(" {self} ")
.fg(tailwind::SLATE.c200)
.bg(self.palette().c900)
.into()
}
fn render_albums(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Hello, World!")
.block(self.block())
.render(area, buf);
}
fn render_playlists(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("Welcome to the Ratatui tabs example!")
.block(self.block())
.render(area, buf);
}
fn render_soundcloud(block: Block<'static>, area: Rect, buf: &mut Buffer, playlists: Option<CloudPlaylists>) {
Paragraph::new("Your playlists from soundcloud:")
.block(block)
.render(area, buf);
}
fn render_youtube(self, area: Rect, buf: &mut Buffer) {
Paragraph::new("I know, these are some basic changes. But I think you got the main idea.")
.block(self.block())
.render(area, buf);
}
/// A block surrounding the tab's content
fn block(&self) -> Block<'static> {
Block::bordered()
.border_set(symbols::border::THICK)
.padding(Padding::horizontal(1))
.border_style(self.palette().c700)
}
pub fn palette(&self) -> tailwind::Palette {
match self {
Self::Albums => tailwind::INDIGO,
Self::Playlists => tailwind::EMERALD,
Self::Soundcloud(_) => tailwind::ORANGE,
Self::Youtube => tailwind::RED,
}
}
pub fn previous(self) -> Self {
let current_index = self.clone().to_usize();
let previous_index = current_index.saturating_sub(1);
Self::from_repr(previous_index).unwrap_or(self)
}
pub fn next(self) -> Self {
let current_index = self.clone().to_usize();
let next_index = current_index.saturating_add(1);
Self::from_repr(next_index).unwrap_or(self)
}
pub fn to_usize(self) -> usize {
SelectedTab::iter().enumerate().find(|(_i, el)| el.as_ref() == self.as_ref()).unwrap().0
}
}

@ -1,9 +1,5 @@
use image::DynamicImage;
use std::{str, process::Command, error::Error, str::FromStr};
use regex::Regex;
use std::io::Write;
use std::path::PathBuf;
use std::{error::Error, process::Command, str, str::FromStr};
use twox_hash::XxHash3_64;
const VENDOR_ID: u16 = 1452;
const PRODUCT_ID: u16 = 4617;
@ -12,7 +8,7 @@ pub fn search_ipod() -> Option<String> {
for device in rusb::devices().unwrap().iter() {
let device_desc = device.device_descriptor().unwrap();
if VENDOR_ID == device_desc.vendor_id() && PRODUCT_ID == device_desc.product_id() {
return get_ipod_path();
return get_ipod_path()
}
}
None
@ -22,33 +18,28 @@ fn list() -> Result<Vec<String>, Box<dyn Error>> {
let mut disks = Vec::new();
let r = match Command::new("diskutil").arg("list").output() {
Ok(s) => s,
Err(e) => return Err(Box::new(e)),
Err(e) => return Err(Box::new(e))
};
if !r.status.success() {
return Ok(disks);
}
if !r.status.success() { return Ok(disks); }
let rg = Regex::new(r"\d:.+ [a-zA-Z0-9].+").unwrap();
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(e) => return Err(Box::new(e)),
Err(e) => return Err(Box::new(e))
};
for cap in Regex::new(r"\/dev\/.+\(external\, physical\):")
.unwrap()
.find_iter(a)
{
for cap in Regex::new(r"\/dev\/.+\(external\, physical\):").unwrap().find_iter(a) {
let mut b = &a[cap.end()..];
let i = match b.find("\n\n") {
Some(r) => r,
None => return Ok(disks),
None => return Ok(disks)
};
b = &b[..i];
for gap in rg.find_iter(b) {
let j = match gap.as_str().rfind(" ") {
Some(r) => r + 1,
None => return Ok(disks),
None => return Ok(disks)
};
let g = &gap.as_str()[j..];
let g= &gap.as_str()[j..];
disks.push(String::from_str(g).unwrap());
}
}
@ -58,20 +49,18 @@ fn list() -> Result<Vec<String>, Box<dyn Error>> {
fn is_ipod(name: &str) -> bool {
let r = match Command::new("diskutil").arg("info").arg(name).output() {
Ok(s) => s,
Err(_e) => return false,
Err(_e) => return false
};
if !r.status.success() {
return false;
}
if !r.status.success() { return false; }
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(_e) => return false,
Err(_e) => return false
};
let cap = Regex::new(r"Media Type:.+\n").unwrap().find(a);
if let Some(g) = cap {
let mut b = g.as_str();
let f = b.rfind(" ").unwrap() + 1;
b = &b[f..b.len() - 1];
b = &b[f..b.len()-1];
return b == "iPod";
}
false
@ -80,94 +69,32 @@ fn is_ipod(name: &str) -> bool {
fn get_mount_point(name: &str) -> Option<String> {
let r = match Command::new("diskutil").arg("info").arg(name).output() {
Ok(s) => s,
Err(_e) => return None,
Err(_e) => return None
};
if !r.status.success() {
return None;
}
if !r.status.success() { return None; }
let a = match str::from_utf8(&r.stdout) {
Ok(r) => r,
Err(_e) => return None,
Err(_e) => return None
};
let cap = Regex::new(r"Mount Point:.+\n").unwrap().find(a);
match cap {
Some(g) => {
let i = g.as_str();
let j = i.rfind(" ").unwrap() + 1;
Some(i[j..i.len() - 1].to_string())
}
None => None,
Some(i[j..i.len()-1].to_string())
},
None => None
}
}
fn get_ipod_path() -> Option<String> {
match list() {
Ok(l) => l
.iter()
Ok(l) => l.iter()
.filter(|d| is_ipod(d))
.filter_map(|d| get_mount_point(d))
.map(|d| get_mount_point(d))
.filter(|d| d.is_some())
.map(|d| d.unwrap())
.last(),
Err(_e) => None,
Err(_e) => None
}
}
// note: this hash function is used to make unique ids for each track. It doesn't aim to generate secure ones.
pub fn hash(data: &[u8]) -> u64 {
XxHash3_64::oneshot(data)
}
pub fn hash_from_path(path: PathBuf) -> u64 {
hash(&std::fs::read(path).unwrap())
}
pub struct IPodImage {
pixels: Vec<u16>,
}
impl IPodImage {
pub fn write(&self, p: PathBuf) {
let mut file = std::fs::File::create(p).unwrap();
let _ = file.write(&self.convert_to_u8());
}
fn convert_to_u8(&self) -> Vec<u8> {
self.pixels
.iter()
.flat_map(|f| [*f as u8, (*f >> 8) as u8])
.collect()
}
}
impl From<DynamicImage> for IPodImage {
fn from(value: DynamicImage) -> Self {
let img_rgba = value.to_rgba8();
let (width, height) = img_rgba.dimensions();
let mut rgb565_data: Vec<u16> = Vec::new();
for y in 0..height {
for x in 0..width {
let pixel = img_rgba.get_pixel(x, y).0;
let r = pixel[0];
let g = pixel[1];
let b = pixel[2];
rgb565_data.push(rgb_to_rgb565(r, g, b));
}
}
Self {
pixels: rgb565_data,
}
}
}
fn rgb_to_rgb565(r: u8, g: u8, b: u8) -> u16 {
let r_565 = (r >> 3) & 0x1F; // Extract top 5 bits
let g_565 = (g >> 2) & 0x3F; // Extract top 6 bits
let b_565 = (b >> 3) & 0x1F; // Extract top 5 bits
((r_565 as u16) << 11) | ((g_565 as u16) << 5) | (b_565 as u16) // Combine to RGB565
}
}

@ -1,55 +0,0 @@
use crate::screen::AppScreen;
use ratatui::layout::{Constraint, Direction, Flex, Layout};
use ratatui::widgets::Paragraph;
use ratatui::{
style::{Style, Stylize},
text::Line,
Frame,
};
use throbber_widgets_tui::{ThrobberState, BOX_DRAWING};
use tui_big_text::{BigText, PixelSize};
#[derive(Debug, Clone, Default)]
pub struct WaitScreen {}
impl AppScreen for WaitScreen {
fn handle_key_event(&mut self, _key_event: crossterm::event::KeyEvent) {}
fn render(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(33); 3])
.split(frame.area());
let simple = throbber_widgets_tui::Throbber::default()
.label("Searching for your iPod")
.throbber_set(BOX_DRAWING);
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()
.build();
frame.render_widget(title, chunks[1]);
}
fn as_any(&mut self) -> &mut dyn std::any::Any {
self
}
}