Compare commits
48 Commits
Author | SHA1 | Date | |
---|---|---|---|
5405087434 | |||
0f52d10198 | |||
913da0c0ef | |||
d482acca94 | |||
934bcb9d9e | |||
5d94131ad6 | |||
d3ffb214ca | |||
8a398ceb2c | |||
d6db006412 | |||
a8fab2b721 | |||
7702b6dd01 | |||
13bb433bfe | |||
a724560d58 | |||
7a75c45fa0 | |||
41ef6bcbb1 | |||
a64f77c2a4 | |||
4d89b9e187 | |||
3bd8f1c75d | |||
6b8b4ef355 | |||
d63296d7b4 | |||
fa786d931d | |||
92d3f7ba77 | |||
b29ef4901e | |||
3a77aff9f1 | |||
e6dcb1bae6 | |||
7a0468d0a8 | |||
eee1a4e49d | |||
edfbbce03e | |||
e4061dde2d | |||
fc23d3a7b6 | |||
1657e328fb | |||
b34032d228 | |||
a72a7b8a25 | |||
495f53ef20 | |||
980f1da3ac | |||
f6b8faa221 | |||
8f8a79411b | |||
58e87e615a | |||
8c3d87133d | |||
ded8897ece | |||
f337a6de1f | |||
adb52e01ac | |||
7a32015a05 | |||
ad2ca1d689 | |||
30422c28eb | |||
f016bd754b | |||
34cf8c0a15 | |||
0e7d9aa8d9 |
1010
Cargo.lock
generated
1010
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@ -6,16 +6,32 @@ 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 = "0.28.1"
|
||||
crossterm = { version = "0.28.1", features = ["event-stream"] }
|
||||
futures = "0.3"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7.12", features = ["codec"] }
|
||||
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" }
|
||||
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
|
47
README.md
Normal file
47
README.md
Normal file
@ -0,0 +1,47 @@
|
||||
<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
|
89
src/component.rs
Normal file
89
src/component.rs
Normal file
@ -0,0 +1,89 @@
|
||||
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,19 +1,51 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, 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)]
|
||||
pub struct YouTubeConfiguration {
|
||||
pub user_id: u64
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub struct SoundCloudConfiguration {
|
||||
pub user_id: u64
|
||||
pub user_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub struct LyricaConfiguration {
|
||||
soundcloud: SoundCloudConfiguration,
|
||||
youtube: YouTubeConfiguration
|
||||
youtube: YouTubeConfiguration,
|
||||
}
|
||||
|
||||
impl LyricaConfiguration {
|
||||
@ -24,4 +56,4 @@ impl LyricaConfiguration {
|
||||
pub fn get_youtube(&self) -> &YouTubeConfiguration {
|
||||
&self.youtube
|
||||
}
|
||||
}
|
||||
}
|
||||
|
260
src/dlp.rs
Normal file
260
src/dlp.rs
Normal file
@ -0,0 +1,260 @@
|
||||
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(())
|
||||
}
|
260
src/file_system.rs
Normal file
260
src/file_system.rs
Normal file
@ -0,0 +1,260 @@
|
||||
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);
|
||||
}
|
||||
}
|
128
src/loading_screen.rs
Normal file
128
src/loading_screen.rs
Normal file
@ -0,0 +1,128 @@
|
||||
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);
|
||||
}
|
||||
}
|
306
src/main.rs
306
src/main.rs
@ -1,103 +1,50 @@
|
||||
use std::{error::Error, io, path::{Path, PathBuf}};
|
||||
|
||||
use crate::file_system::FileSystem;
|
||||
use color_eyre::Result;
|
||||
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 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 tokio_util::sync::CancellationToken;
|
||||
use itunesdb::xobjects::XDatabase;
|
||||
use ratatui::prelude::Constraint::{Length, Min};
|
||||
use wait_screen::WaitScreen;
|
||||
|
||||
mod util;
|
||||
mod component;
|
||||
mod config;
|
||||
mod tabs;
|
||||
mod dlp;
|
||||
mod file_system;
|
||||
mod loading_screen;
|
||||
mod main_screen;
|
||||
mod screen;
|
||||
mod sync;
|
||||
mod util;
|
||||
mod wait_screen;
|
||||
|
||||
fn get_configs_dir() -> PathBuf {
|
||||
let mut p = dirs::home_dir().unwrap();
|
||||
p.push(".lyrica");
|
||||
p
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Eq, Hash, PartialEq)]
|
||||
enum AppState {
|
||||
IPodWait,
|
||||
MainScreen(crate::screen::MainScreen)
|
||||
MainScreen,
|
||||
LoadingScreen,
|
||||
FileSystem,
|
||||
}
|
||||
|
||||
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,
|
||||
@ -105,140 +52,133 @@ pub struct App {
|
||||
|
||||
impl Default for App {
|
||||
fn default() -> Self {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let (jx, mut jr) = mpsc::unbounded_channel();
|
||||
let (tx, rx) = mpsc::channel(10);
|
||||
let (jx, jr) = mpsc::unbounded_channel();
|
||||
let token = CancellationToken::new();
|
||||
initialize_async_service(tx, jr, token.clone());
|
||||
|
||||
sync::initialize_async_service(tx, jr, token.clone());
|
||||
|
||||
let _ = jx.send(AppEvent::SearchIPod);
|
||||
Self { state: AppState::IPodWait, receiver: rx, sender: jx, token }
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
|
||||
pub async fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> io::Result<()> {
|
||||
let mut reader = EventStream::new();
|
||||
while !self.token.is_cancelled() {
|
||||
let _ = self.handle_events(&mut reader).await;
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
self.handle_events()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut Frame) {
|
||||
frame.render_widget(self.state.clone(), frame.area());
|
||||
fn draw(&mut self, frame: &mut Frame) {
|
||||
self.screens.get(&self.state).unwrap().render(frame);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
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);
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(200)) => {
|
||||
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
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(),
|
||||
_ => {}
|
||||
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) {
|
||||
self.token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
|
||||
let mut stderr = io::stdout();
|
||||
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();
|
||||
app.run(&mut terminal);
|
||||
let _ = app.run(&mut terminal).await;
|
||||
|
||||
// restore terminal
|
||||
disable_raw_mode()?;
|
||||
@ -250,4 +190,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
525
src/main_screen.rs
Normal file
525
src/main_screen.rs
Normal file
@ -0,0 +1,525 @@
|
||||
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,56 +1,12 @@
|
||||
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 std::any::Any;
|
||||
|
||||
use crate::tabs::SelectedTab;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MainScreen {
|
||||
pub selected_tab: SelectedTab,
|
||||
pub soundcloud: Option<CloudPlaylists>
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
1095
src/sync.rs
Normal file
1095
src/sync.rs
Normal file
File diff suppressed because it is too large
Load Diff
97
src/tabs.rs
97
src/tabs.rs
@ -1,97 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
123
src/util.rs
123
src/util.rs
@ -1,5 +1,9 @@
|
||||
use std::{str, process::Command, error::Error, str::FromStr};
|
||||
use image::DynamicImage;
|
||||
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;
|
||||
@ -8,7 +12,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
|
||||
@ -18,28 +22,33 @@ 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());
|
||||
}
|
||||
}
|
||||
@ -49,18 +58,20 @@ 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
|
||||
@ -69,32 +80,94 @@ 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))
|
||||
.map(|d| get_mount_point(d))
|
||||
.filter(|d| d.is_some())
|
||||
.map(|d| d.unwrap())
|
||||
.filter_map(|d| get_mount_point(d))
|
||||
.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
|
||||
}
|
||||
|
55
src/wait_screen.rs
Normal file
55
src/wait_screen.rs
Normal file
@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user