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>"]
|
authors = ["Michael Wain <alterwain@protonmail.com>"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = "0.4.39"
|
||||||
rusb = "0.9.4"
|
rusb = "0.9.4"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
toml = "0.8.20"
|
toml = "0.8.20"
|
||||||
serde = "1.0.217"
|
serde = "1.0.217"
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde-xml-rs = "0.6.0"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
ratatui = { version = "0.29.0", features = ["all-widgets"] }
|
ratatui = { version = "0.29.0", features = ["all-widgets"] }
|
||||||
color-eyre = "0.6.3"
|
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 = { version = "1", features = ["full"] }
|
||||||
tokio-util = { version = "0.7.12", features = ["codec"] }
|
tokio-util = { version = "0.7.12", features = ["codec"] }
|
||||||
strum = { version = "0.27", features = ["derive"] }
|
soundcloud = { version = "0.1.11", git = "https://gitea.awain.net/alterwain/soundcloud_api.git" }
|
||||||
soundcloud = { version = "0.1.1", 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.1", git = "https://gitea.awain.net/alterwain/ITunesDB.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 struct YouTubeConfiguration {
|
||||||
pub user_id: u64
|
pub user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||||
pub struct SoundCloudConfiguration {
|
pub struct SoundCloudConfiguration {
|
||||||
pub user_id: u64
|
pub user_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||||
pub struct LyricaConfiguration {
|
pub struct LyricaConfiguration {
|
||||||
soundcloud: SoundCloudConfiguration,
|
soundcloud: SoundCloudConfiguration,
|
||||||
youtube: YouTubeConfiguration
|
youtube: YouTubeConfiguration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LyricaConfiguration {
|
impl LyricaConfiguration {
|
||||||
@ -24,4 +56,4 @@ impl LyricaConfiguration {
|
|||||||
pub fn get_youtube(&self) -> &YouTubeConfiguration {
|
pub fn get_youtube(&self) -> &YouTubeConfiguration {
|
||||||
&self.youtube
|
&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 color_eyre::Result;
|
||||||
use config::LyricaConfiguration;
|
use crossterm::{
|
||||||
use crossterm::{event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}};
|
event::{
|
||||||
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};
|
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent,
|
||||||
use screen::MainScreen;
|
KeyEventKind,
|
||||||
use soundcloud::sobjects::CloudPlaylists;
|
},
|
||||||
use strum::IntoEnumIterator;
|
execute,
|
||||||
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}};
|
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 tokio_util::sync::CancellationToken;
|
||||||
use itunesdb::xobjects::XDatabase;
|
use wait_screen::WaitScreen;
|
||||||
use ratatui::prelude::Constraint::{Length, Min};
|
|
||||||
|
|
||||||
mod util;
|
mod component;
|
||||||
mod config;
|
mod config;
|
||||||
mod tabs;
|
mod dlp;
|
||||||
|
mod file_system;
|
||||||
|
mod loading_screen;
|
||||||
|
mod main_screen;
|
||||||
mod screen;
|
mod screen;
|
||||||
|
mod sync;
|
||||||
|
mod util;
|
||||||
|
mod wait_screen;
|
||||||
|
|
||||||
fn get_configs_dir() -> PathBuf {
|
#[derive(Eq, Hash, PartialEq)]
|
||||||
let mut p = dirs::home_dir().unwrap();
|
|
||||||
p.push(".lyrica");
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
enum AppState {
|
enum AppState {
|
||||||
IPodWait,
|
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 {
|
pub struct App {
|
||||||
state: AppState,
|
state: AppState,
|
||||||
|
screens: HashMap<AppState, Box<dyn AppScreen>>,
|
||||||
receiver: Receiver<AppEvent>,
|
receiver: Receiver<AppEvent>,
|
||||||
sender: UnboundedSender<AppEvent>,
|
sender: UnboundedSender<AppEvent>,
|
||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
@ -105,140 +52,133 @@ pub struct App {
|
|||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let (tx, mut rx) = mpsc::channel(1);
|
let (tx, rx) = mpsc::channel(10);
|
||||||
let (jx, mut jr) = mpsc::unbounded_channel();
|
let (jx, jr) = mpsc::unbounded_channel();
|
||||||
let token = CancellationToken::new();
|
let token = CancellationToken::new();
|
||||||
initialize_async_service(tx, jr, token.clone());
|
|
||||||
|
sync::initialize_async_service(tx, jr, token.clone());
|
||||||
|
|
||||||
let _ = jx.send(AppEvent::SearchIPod);
|
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 {
|
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() {
|
while !self.token.is_cancelled() {
|
||||||
|
let _ = self.handle_events(&mut reader).await;
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
self.handle_events()?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&self, frame: &mut Frame) {
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
frame.render_widget(self.state.clone(), frame.area());
|
self.screens.get(&self.state).unwrap().render(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_events(&mut self) -> io::Result<()> {
|
async fn handle_events(&mut self, reader: &mut EventStream) {
|
||||||
match event::read()? {
|
tokio::select! {
|
||||||
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
|
Some(Ok(event)) = reader.next() => {
|
||||||
self.handle_key_event(key_event)
|
match event {
|
||||||
}
|
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);
|
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
_ => {}
|
},
|
||||||
|
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) {
|
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||||
if let AppState::MainScreen(screen) = &self.state {
|
self.screens
|
||||||
let mut screen = screen.clone();
|
.get_mut(&self.state)
|
||||||
screen.handle_key_event(key_event);
|
.unwrap()
|
||||||
self.state = AppState::MainScreen(screen);
|
.handle_key_event(key_event);
|
||||||
}
|
if let KeyCode::Char('q') = key_event.code {
|
||||||
match key_event.code {
|
self.exit()
|
||||||
KeyCode::Char('q') => self.exit(),
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exit(&mut self) {
|
fn exit(&mut self) {
|
||||||
self.token.cancel();
|
self.token.cancel();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
fn get_screen<T>(&mut self, state: &AppState) -> &mut T
|
||||||
fn render_main_screen(area: Rect, buf: &mut Buffer, screen: &mut MainScreen) {
|
where
|
||||||
let vertical = Layout::vertical([Length(1), Min(0), Length(1)]);
|
T: 'static + AppScreen,
|
||||||
let [header_area, inner_area, footer_area] = vertical.areas(area);
|
{
|
||||||
|
let a = self.screens.get_mut(state).unwrap();
|
||||||
let horizontal = Layout::horizontal([Min(0), Length(7)]);
|
a.as_any().downcast_mut::<T>().unwrap()
|
||||||
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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<(), Box<dyn Error>> {
|
||||||
enable_raw_mode()?;
|
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)?;
|
execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stderr);
|
let backend = CrosstermBackend::new(stderr);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
// create app and run it
|
// create app and run it
|
||||||
let mut app = App::default();
|
let mut app = App::default();
|
||||||
app.run(&mut terminal);
|
let _ = app.run(&mut terminal).await;
|
||||||
|
|
||||||
// restore terminal
|
// restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
@ -250,4 +190,4 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
Ok(())
|
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 std::any::Any;
|
||||||
use ratatui::{buffer::Buffer, layout::Rect, style::{Color, Stylize}, text::Line, widgets::{Tabs, Widget}};
|
|
||||||
use soundcloud::sobjects::CloudPlaylists;
|
|
||||||
use strum::IntoEnumIterator;
|
|
||||||
|
|
||||||
use crate::tabs::SelectedTab;
|
use crossterm::event::KeyEvent;
|
||||||
|
use ratatui::Frame;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
pub trait AppScreen {
|
||||||
pub struct MainScreen {
|
fn handle_key_event(&mut self, key_event: KeyEvent);
|
||||||
pub selected_tab: SelectedTab,
|
|
||||||
pub soundcloud: Option<CloudPlaylists>
|
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 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 VENDOR_ID: u16 = 1452;
|
||||||
const PRODUCT_ID: u16 = 4617;
|
const PRODUCT_ID: u16 = 4617;
|
||||||
@ -8,7 +12,7 @@ pub fn search_ipod() -> Option<String> {
|
|||||||
for device in rusb::devices().unwrap().iter() {
|
for device in rusb::devices().unwrap().iter() {
|
||||||
let device_desc = device.device_descriptor().unwrap();
|
let device_desc = device.device_descriptor().unwrap();
|
||||||
if VENDOR_ID == device_desc.vendor_id() && PRODUCT_ID == device_desc.product_id() {
|
if VENDOR_ID == device_desc.vendor_id() && PRODUCT_ID == device_desc.product_id() {
|
||||||
return get_ipod_path()
|
return get_ipod_path();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
@ -18,28 +22,33 @@ fn list() -> Result<Vec<String>, Box<dyn Error>> {
|
|||||||
let mut disks = Vec::new();
|
let mut disks = Vec::new();
|
||||||
let r = match Command::new("diskutil").arg("list").output() {
|
let r = match Command::new("diskutil").arg("list").output() {
|
||||||
Ok(s) => s,
|
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 rg = Regex::new(r"\d:.+ [a-zA-Z0-9].+").unwrap();
|
||||||
let a = match str::from_utf8(&r.stdout) {
|
let a = match str::from_utf8(&r.stdout) {
|
||||||
Ok(r) => r,
|
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 mut b = &a[cap.end()..];
|
||||||
let i = match b.find("\n\n") {
|
let i = match b.find("\n\n") {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => return Ok(disks)
|
None => return Ok(disks),
|
||||||
};
|
};
|
||||||
b = &b[..i];
|
b = &b[..i];
|
||||||
for gap in rg.find_iter(b) {
|
for gap in rg.find_iter(b) {
|
||||||
let j = match gap.as_str().rfind(" ") {
|
let j = match gap.as_str().rfind(" ") {
|
||||||
Some(r) => r + 1,
|
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());
|
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 {
|
fn is_ipod(name: &str) -> bool {
|
||||||
let r = match Command::new("diskutil").arg("info").arg(name).output() {
|
let r = match Command::new("diskutil").arg("info").arg(name).output() {
|
||||||
Ok(s) => s,
|
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) {
|
let a = match str::from_utf8(&r.stdout) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_e) => return false
|
Err(_e) => return false,
|
||||||
};
|
};
|
||||||
let cap = Regex::new(r"Media Type:.+\n").unwrap().find(a);
|
let cap = Regex::new(r"Media Type:.+\n").unwrap().find(a);
|
||||||
if let Some(g) = cap {
|
if let Some(g) = cap {
|
||||||
let mut b = g.as_str();
|
let mut b = g.as_str();
|
||||||
let f = b.rfind(" ").unwrap() + 1;
|
let f = b.rfind(" ").unwrap() + 1;
|
||||||
b = &b[f..b.len()-1];
|
b = &b[f..b.len() - 1];
|
||||||
return b == "iPod";
|
return b == "iPod";
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
@ -69,32 +80,94 @@ fn is_ipod(name: &str) -> bool {
|
|||||||
fn get_mount_point(name: &str) -> Option<String> {
|
fn get_mount_point(name: &str) -> Option<String> {
|
||||||
let r = match Command::new("diskutil").arg("info").arg(name).output() {
|
let r = match Command::new("diskutil").arg("info").arg(name).output() {
|
||||||
Ok(s) => s,
|
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) {
|
let a = match str::from_utf8(&r.stdout) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_e) => return None
|
Err(_e) => return None,
|
||||||
};
|
};
|
||||||
let cap = Regex::new(r"Mount Point:.+\n").unwrap().find(a);
|
let cap = Regex::new(r"Mount Point:.+\n").unwrap().find(a);
|
||||||
match cap {
|
match cap {
|
||||||
Some(g) => {
|
Some(g) => {
|
||||||
let i = g.as_str();
|
let i = g.as_str();
|
||||||
let j = i.rfind(" ").unwrap() + 1;
|
let j = i.rfind(" ").unwrap() + 1;
|
||||||
Some(i[j..i.len()-1].to_string())
|
Some(i[j..i.len() - 1].to_string())
|
||||||
},
|
}
|
||||||
None => None
|
None => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_ipod_path() -> Option<String> {
|
fn get_ipod_path() -> Option<String> {
|
||||||
match list() {
|
match list() {
|
||||||
Ok(l) => l.iter()
|
Ok(l) => l
|
||||||
|
.iter()
|
||||||
.filter(|d| is_ipod(d))
|
.filter(|d| is_ipod(d))
|
||||||
.map(|d| get_mount_point(d))
|
.filter_map(|d| get_mount_point(d))
|
||||||
.filter(|d| d.is_some())
|
|
||||||
.map(|d| d.unwrap())
|
|
||||||
.last(),
|
.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