commit 26d2b2c325d58dda6aa6c7c37c57ac2bec2edad9 Author: alterwain Date: Sun Feb 9 17:57:04 2025 +0300 Lyrica initial "] + +[dependencies] +rusb = "0.9.4" +regex = "1.11.1" +ratatui = { version = "0.29.0", features = ["all-widgets"] } +color-eyre = "0.6.3" +crossterm = "0.28.1" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7.12", features = ["codec"] } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..600aa89 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,157 @@ +use std::io; + +use color_eyre::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, symbols::border, text::{Line, Text}, widgets::{Block, Paragraph, Widget}, DefaultTerminal, Frame}; +use tokio::sync::mpsc::{self, Receiver, Sender, UnboundedReceiver, UnboundedSender}; +use tokio_util::sync::CancellationToken; + +mod util; + +#[derive(Debug)] +enum AppState { + IPodWait, + MainScreen, + SoundCloud, + Youtube, + Preferences +} + +enum AppEvent { + SearchIPod, + IPodFound(String), + IPodNotFound, +} + +fn initialize_async_service(sender: Sender, receiver: UnboundedReceiver, 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; + } + }, + _ => {} + } + } + } + } + } + }); +} + +#[derive(Debug)] +pub struct App { + state: AppState, + receiver: Receiver, + sender: UnboundedSender, + token: CancellationToken, +} + +impl Default for App { + fn default() -> Self { + let (tx, mut rx) = mpsc::channel(1); + let (jx, mut jr) = mpsc::unbounded_channel(); + let token = CancellationToken::new(); + initialize_async_service(tx, jr, token.clone()); + let _ = jx.send(AppEvent::SearchIPod); + Self { state: AppState::IPodWait, receiver: rx, sender: jx, token } + } +} + +impl App { + pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { + while !self.token.is_cancelled() { + terminal.draw(|frame| self.draw(frame))?; + self.handle_events()?; + } + Ok(()) + } + + fn draw(&self, frame: &mut Frame) { + frame.render_widget(self, frame.area()); + } + + 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; + }, + AppEvent::IPodNotFound => { + let _ = self.sender.send(AppEvent::SearchIPod); + } + _ => {} + } + } + Ok(()) + } + + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.code == KeyCode::Char('q') { + self.exit(); + } + } + + fn exit(&mut self) { + self.token.cancel(); + } + + fn render_waiting_screen(&self, area: Rect, buf: &mut Buffer) { + let title = Line::from(" Lyrica ".bold()); + let instructions = Line::from(vec![ + " Quit ".into(), + " ".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 &App { + fn render(self, area: Rect, buf: &mut Buffer) { + match self.state { + AppState::IPodWait => self.render_waiting_screen(area, buf), + _ => {} + } + } +} + +#[tokio::main] +async fn main() -> io::Result<()> { + let mut terminal = ratatui::init(); + let app_result = App::default().run(&mut terminal); + ratatui::restore(); + app_result +} \ No newline at end of file diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..34da1d9 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,100 @@ +use std::{str, process::Command, error::Error, str::FromStr}; +use regex::Regex; + +const VENDOR_ID: u16 = 1452; +const PRODUCT_ID: u16 = 4617; + +pub fn search_ipod() -> Option { + 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() + } + } + None +} + +fn list() -> Result, Box> { + let mut disks = Vec::new(); + let r = match Command::new("diskutil").arg("list").output() { + Ok(s) => s, + Err(e) => return Err(Box::new(e)) + }; + 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)) + }; + 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) + }; + 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) + }; + + let g= &gap.as_str()[j..]; + disks.push(String::from_str(g).unwrap()); + } + } + Ok(disks) +} + +fn is_ipod(name: &str) -> bool { + let r = match Command::new("diskutil").arg("info").arg(name).output() { + Ok(s) => s, + Err(_e) => return false + }; + if !r.status.success() { return false; } + let a = match str::from_utf8(&r.stdout) { + Ok(r) => r, + 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]; + return b == "iPod"; + } + false +} + +fn get_mount_point(name: &str) -> Option { + let r = match Command::new("diskutil").arg("info").arg(name).output() { + Ok(s) => s, + Err(_e) => return None + }; + if !r.status.success() { return None; } + let a = match str::from_utf8(&r.stdout) { + Ok(r) => r, + 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 + } +} + +fn get_ipod_path() -> Option { + match list() { + Ok(l) => l.iter() + .filter(|d| is_ipod(d)) + .map(|d| get_mount_point(d)) + .filter(|d| d.is_some()) + .map(|d| d.unwrap()) + .last(), + Err(_e) => None + } +} \ No newline at end of file