First prototype
This commit is contained in:
2
.codespellrc
Normal file
2
.codespellrc
Normal file
@@ -0,0 +1,2 @@
|
||||
[codespell]
|
||||
ignore-words-list = ratatui,crate
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
PASSWORD="SuperStrongPassword42"
|
||||
USERNAME="bob"
|
||||
URL="https://music.yourdomain.com"
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
.env
|
||||
2964
Cargo.lock
generated
Normal file
2964
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "akula"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
log = "0.4"
|
||||
color-eyre = "0.6"
|
||||
dotenv = "0.15"
|
||||
serde = { version = "1", features = ["derive"]}
|
||||
itertools = "0.14"
|
||||
|
||||
ratatui = "0.29"
|
||||
crossterm = { version = "0.28.1", features = ["event-stream","serde"] }
|
||||
|
||||
tokio = { version = "1.43", features = ["macros", "rt"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = "0.7"
|
||||
futures = "0.3"
|
||||
|
||||
reqwest = { version = "0.12", features = ["blocking", "stream"] }
|
||||
subsonic-types = "0.2.0"
|
||||
|
||||
rand = "0.9"
|
||||
md5 = "0.7"
|
||||
|
||||
rodio = "0.20"
|
||||
cpal = { version = "0.15", features = ["jack"] }
|
||||
63
src/app.rs
Normal file
63
src/app.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEvent};
|
||||
use ratatui::{Terminal, prelude::CrosstermBackend};
|
||||
use reqwest::Client;
|
||||
use std::io::Stdout;
|
||||
|
||||
use crate::{
|
||||
event::{Event, Events},
|
||||
player::Player,
|
||||
widgets::{Queue, QueueWidget},
|
||||
};
|
||||
|
||||
pub struct App {
|
||||
client: Client,
|
||||
player: Player,
|
||||
queue: Queue,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
player: Player::new(),
|
||||
client: Client::default(),
|
||||
queue: Queue::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
&mut self,
|
||||
mut terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
mut events: Events,
|
||||
) -> Result<()> {
|
||||
self.queue.request_random(self.client.clone());
|
||||
loop {
|
||||
if let Some(evt) = events.next().await {
|
||||
match evt {
|
||||
Event::Render => {
|
||||
terminal.draw(|frame| {
|
||||
frame.render_stateful_widget(
|
||||
QueueWidget,
|
||||
frame.area(),
|
||||
&mut self.queue,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
Event::Crossterm(CrosstermEvent::Key(KeyEvent { code, .. })) => match code {
|
||||
KeyCode::Up => self.queue.up(),
|
||||
KeyCode::Down => self.queue.down(),
|
||||
KeyCode::Enter => self
|
||||
.player
|
||||
.play(self.queue.get_select_url(), self.client.clone()),
|
||||
|
||||
KeyCode::Char('q') => break,
|
||||
_ => {}
|
||||
},
|
||||
Event::Quit => break,
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
67
src/event.rs
Normal file
67
src/event.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use crossterm::event::{Event as CrosstermEvent, *};
|
||||
use futures::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{pin::Pin, time::Duration};
|
||||
use tokio::time::interval;
|
||||
use tokio_stream::{StreamMap, wrappers::IntervalStream};
|
||||
|
||||
pub struct Events {
|
||||
streams: StreamMap<StreamName, Pin<Box<dyn Stream<Item = Event>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
|
||||
enum StreamName {
|
||||
Ticks,
|
||||
Render,
|
||||
Crossterm,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
Init,
|
||||
Quit,
|
||||
Error,
|
||||
Closed,
|
||||
Tick,
|
||||
Render,
|
||||
Crossterm(CrosstermEvent),
|
||||
}
|
||||
|
||||
impl Events {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
streams: StreamMap::from_iter([
|
||||
(StreamName::Ticks, tick_stream()),
|
||||
(StreamName::Render, render_stream()),
|
||||
(StreamName::Crossterm, crossterm_stream()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn next(&mut self) -> Option<Event> {
|
||||
self.streams.next().await.map(|(_name, event)| event)
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
||||
let tick_delay = Duration::from_secs_f64(1.0);
|
||||
let tick_interval = interval(tick_delay);
|
||||
Box::pin(IntervalStream::new(tick_interval).map(|_| Event::Tick))
|
||||
}
|
||||
|
||||
fn render_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
||||
let render_delay = Duration::from_secs_f64(1.0 / 15.);
|
||||
let render_interval = interval(render_delay);
|
||||
Box::pin(IntervalStream::new(render_interval).map(|_| Event::Render))
|
||||
}
|
||||
|
||||
fn crossterm_stream() -> Pin<Box<dyn Stream<Item = Event>>> {
|
||||
Box::pin(EventStream::new().fuse().filter_map(|event| async move {
|
||||
match event {
|
||||
// Ignore key release / repeat events
|
||||
Ok(CrosstermEvent::Key(key)) if key.kind == KeyEventKind::Release => None,
|
||||
Ok(event) => Some(Event::Crossterm(event)),
|
||||
Err(_) => Some(Event::Error),
|
||||
}
|
||||
}))
|
||||
}
|
||||
165
src/lib.rs
Normal file
165
src/lib.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use color_eyre::Result;
|
||||
use cpal::traits::HostTrait;
|
||||
use crossterm::event::{self, Event};
|
||||
use futures::StreamExt;
|
||||
use rand::{Rng, distr::Alphanumeric, rng};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState},
|
||||
};
|
||||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::{BufReader, ErrorKind},
|
||||
time::Duration,
|
||||
};
|
||||
use subsonic_types::{
|
||||
common::Version,
|
||||
request::{self, Authentication, Request, SubsonicRequest},
|
||||
response::{Child, Response, ResponseBody},
|
||||
};
|
||||
use tokio::task::{JoinSet, spawn_blocking};
|
||||
|
||||
const SALT_SIZE: usize = 36;
|
||||
|
||||
async fn run(mut terminal: DefaultTerminal) -> Result<()> {
|
||||
let res = test_subsonic().await?;
|
||||
|
||||
let song_list = if let ResponseBody::RandomSongs(songs) = res {
|
||||
songs.song
|
||||
} else {
|
||||
return Err(std::io::Error::new(ErrorKind::Other, "h").into());
|
||||
};
|
||||
let items: Vec<_> = song_list
|
||||
.iter()
|
||||
.map(|song| {
|
||||
ListItem::new(Line::from(vec![Span::styled(
|
||||
song.title.clone(),
|
||||
Style::default(),
|
||||
)]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items);
|
||||
|
||||
// This should be stored outside of the function in your application state.
|
||||
let mut state = ListState::default();
|
||||
|
||||
*state.offset_mut() = 1; // display the second item and onwards
|
||||
state.select(Some(0)); // select the forth item (0-indexed)
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
frame.render_stateful_widget(&list, frame.area(), &mut state);
|
||||
})?;
|
||||
if matches!(event::read()?, Event::Key(_)) {
|
||||
break Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn async_audio_dl() -> color_eyre::Result<()> {
|
||||
let url = "https://music.domain.com/rest/download?u=jika&t=f61772aa3ecf7c4015df08735c9f26fd&s=0fea81&f=json&v=1.8.0&c=NavidromeUI&id=adc722f5dea2ea434c03c8c14f54865c&format=raw&bitrate=0";
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut path = env::temp_dir();
|
||||
path.push("ro.mp3");
|
||||
let file = File::create(path)?;
|
||||
|
||||
let mut set = JoinSet::new();
|
||||
|
||||
let fut = async move {
|
||||
let mut tmp_file = tokio::fs::File::from(file);
|
||||
let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream();
|
||||
|
||||
while let Some(item) = byte_stream.next().await {
|
||||
tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
set.spawn(fut);
|
||||
let device = cpal::host_from_id(cpal::available_hosts()
|
||||
.into_iter()
|
||||
.find(|id| *id == cpal::HostId::Jack)
|
||||
.expect(
|
||||
"make sure --features jack is specified. only works on OSes where jack is available",
|
||||
)).expect("jack host unavailable").default_output_device().unwrap();
|
||||
|
||||
let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap();
|
||||
set.spawn(async {
|
||||
let _ = test_audio(handle).await;
|
||||
});
|
||||
|
||||
while let Some(res) = set.join_next().await {
|
||||
res?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn test_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> {
|
||||
let sink = rodio::Sink::try_new(&handle).unwrap();
|
||||
|
||||
//let response = reqwest::blocking::get(url)?;
|
||||
//sink.append(rodio::Decoder::new(Cursor::new(response.bytes()?))?);
|
||||
|
||||
//let file = std::fs::File::open("/home/jika/Downloads/Let It Be - Proket Remix.mp3").unwrap();
|
||||
let file = std::fs::File::open("/tmp/ro.mp3").unwrap();
|
||||
|
||||
let a = loop {
|
||||
if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) {
|
||||
break o;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
};
|
||||
|
||||
spawn_blocking(move || {
|
||||
sink.append(a);
|
||||
sink.sleep_until_end();
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn test_subsonic() -> color_eyre::Result<ResponseBody> {
|
||||
let base_url = "http://192.168.0.166:4533";
|
||||
|
||||
let salt: String = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(SALT_SIZE)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let pre_t = "password".to_string() + &salt;
|
||||
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
|
||||
|
||||
let random = Request {
|
||||
username: "bob".into(),
|
||||
authentication: Authentication::Token { token, salt },
|
||||
version: Version::LATEST,
|
||||
client: "ping-example".into(),
|
||||
format: None,
|
||||
body: request::lists::GetRandomSongs {
|
||||
size: Some(5),
|
||||
genre: None,
|
||||
to_year: None,
|
||||
from_year: None,
|
||||
music_folder_id: None,
|
||||
},
|
||||
};
|
||||
let request_url = format!(
|
||||
"{}{}?{}",
|
||||
base_url,
|
||||
request::lists::GetRandomSongs::PATH,
|
||||
random.to_query()
|
||||
);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response_contents = client.get(&request_url).send().await?.text().await?;
|
||||
let response = Response::from_xml(&response_contents)?;
|
||||
|
||||
Ok(response.body)
|
||||
}
|
||||
30
src/main.rs
Normal file
30
src/main.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
mod app;
|
||||
mod event;
|
||||
mod player;
|
||||
mod widgets;
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use dotenv::dotenv;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
dotenv().ok();
|
||||
|
||||
let terminal = ratatui::init();
|
||||
set_panic_hook();
|
||||
|
||||
let events = event::Events::new();
|
||||
app::App::new().run(terminal, events).await?;
|
||||
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
ratatui::restore();
|
||||
hook(panic_info);
|
||||
}));
|
||||
}
|
||||
76
src/player.rs
Normal file
76
src/player.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::{env, fs::File, time::Duration};
|
||||
|
||||
use cpal::traits::HostTrait;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use rodio::{OutputStream, OutputStreamHandle};
|
||||
use std::io::BufReader;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
pub struct Player {
|
||||
handle: OutputStreamHandle,
|
||||
_stream: OutputStream,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new() -> Self {
|
||||
let device = cpal::host_from_id(cpal::available_hosts()
|
||||
.into_iter()
|
||||
.find(|id| *id == cpal::HostId::Jack)
|
||||
.expect(
|
||||
"make sure --features jack is specified. only works on OSes where jack is available",
|
||||
)).expect("jack host unavailable").default_output_device().unwrap();
|
||||
|
||||
let (_stream, handle) = rodio::OutputStream::try_from_device(&device).unwrap();
|
||||
|
||||
Self { _stream, handle }
|
||||
}
|
||||
|
||||
pub fn play(&self, url: String, client: Client) {
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move { async_audio_dl(url, client).await });
|
||||
|
||||
let handle = self.handle.clone();
|
||||
tokio::spawn(async move {
|
||||
play_audio(handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn async_audio_dl(url: String, client: Client) -> color_eyre::Result<()> {
|
||||
let mut path = env::temp_dir();
|
||||
path.push("ro.mp3");
|
||||
let file = File::create(path)?;
|
||||
|
||||
let mut tmp_file = tokio::fs::File::from(file);
|
||||
let mut byte_stream = client.get(url).send().await.unwrap().bytes_stream();
|
||||
|
||||
while let Some(item) = byte_stream.next().await {
|
||||
tokio::io::copy(&mut item.unwrap().as_ref(), &mut tmp_file)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn play_audio(handle: rodio::OutputStreamHandle) -> color_eyre::Result<()> {
|
||||
let sink = rodio::Sink::try_new(&handle).unwrap();
|
||||
|
||||
let file = std::fs::File::open("/tmp/ro.mp3").unwrap();
|
||||
|
||||
let a = loop {
|
||||
if let Ok(o) = rodio::Decoder::new(BufReader::new(file.try_clone()?)) {
|
||||
break o;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
};
|
||||
|
||||
spawn_blocking(move || {
|
||||
sink.append(a);
|
||||
sink.sleep_until_end();
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3
src/widgets/mod.rs
Normal file
3
src/widgets/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod queue;
|
||||
|
||||
pub use queue::*;
|
||||
178
src/widgets/queue.rs
Normal file
178
src/widgets/queue.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use itertools::Itertools;
|
||||
use rand::{Rng, distr::Alphanumeric, rng};
|
||||
use ratatui::{layout::Constraint::*, prelude::*, widgets::*};
|
||||
use ratatui::{
|
||||
prelude::{Buffer, Rect},
|
||||
widgets::{StatefulWidget, Table, TableState},
|
||||
};
|
||||
use reqwest::Client;
|
||||
use std::env;
|
||||
use std::{
|
||||
io::{ErrorKind, Stdout},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use subsonic_types::{
|
||||
common::Version,
|
||||
request::{self, Authentication, Request, SubsonicRequest},
|
||||
response::{Child, Response, ResponseBody},
|
||||
};
|
||||
|
||||
const SALT_SIZE: usize = 36;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Queue {
|
||||
table_state: TableState,
|
||||
songs_list: Arc<Mutex<Vec<Child>>>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
pub fn request_random(&self, client: Client) {
|
||||
let songs_list = self.songs_list.clone();
|
||||
tokio::spawn(async move {
|
||||
get_random(songs_list, client).await;
|
||||
});
|
||||
}
|
||||
pub fn down(&mut self) {
|
||||
self.table_state.select_next();
|
||||
}
|
||||
pub fn up(&mut self) {
|
||||
self.table_state.select_previous();
|
||||
}
|
||||
|
||||
pub fn get_select_url(&self) -> String {
|
||||
let id = self.table_state.selected().unwrap();
|
||||
let song = self.songs_list.lock().unwrap()[id].clone();
|
||||
let base_url = get_base_url();
|
||||
|
||||
let salt: String = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(SALT_SIZE)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let pre_t = get_password() + &salt;
|
||||
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
|
||||
|
||||
let download = Request {
|
||||
username: get_username(),
|
||||
authentication: Authentication::Token { token, salt },
|
||||
version: Version::LATEST,
|
||||
client: "ping-example".into(),
|
||||
format: None,
|
||||
body: request::retrieval::Download { id: song.id },
|
||||
};
|
||||
format!(
|
||||
"{}{}?{}",
|
||||
base_url,
|
||||
request::retrieval::Download::PATH,
|
||||
download.to_query()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_base_url() -> String {
|
||||
env::var("URL").expect("Failed to find 'URL' environment variable")
|
||||
}
|
||||
|
||||
fn get_username() -> String {
|
||||
env::var("USERNAME").expect("Failed to find 'USERNAME' environment variable")
|
||||
}
|
||||
|
||||
fn get_password() -> String {
|
||||
env::var("PASSWORD").expect("Failed to find 'PASSWORD' environment variable")
|
||||
}
|
||||
|
||||
async fn get_random(songs_list: Arc<Mutex<Vec<Child>>>, client: Client) -> Result<()> {
|
||||
let base_url = get_base_url();
|
||||
|
||||
let salt: String = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(SALT_SIZE)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let pre_t = get_password() + &salt;
|
||||
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
|
||||
|
||||
let random = Request {
|
||||
username: get_username(),
|
||||
authentication: Authentication::Token { token, salt },
|
||||
version: Version::LATEST,
|
||||
client: "ping-example".into(),
|
||||
format: None,
|
||||
body: request::lists::GetRandomSongs {
|
||||
size: Some(5),
|
||||
genre: None,
|
||||
to_year: None,
|
||||
from_year: None,
|
||||
music_folder_id: None,
|
||||
},
|
||||
};
|
||||
let request_url = format!(
|
||||
"{}{}?{}",
|
||||
base_url,
|
||||
request::lists::GetRandomSongs::PATH,
|
||||
random.to_query()
|
||||
);
|
||||
|
||||
let response_contents = client.get(&request_url).send().await?.text().await?;
|
||||
let response = Response::from_xml(&response_contents)?;
|
||||
let song_list = if let ResponseBody::RandomSongs(songs) = response.body {
|
||||
songs.song
|
||||
} else {
|
||||
return Err(std::io::Error::new(ErrorKind::Other, "h").into());
|
||||
};
|
||||
let mut a = songs_list.lock().unwrap();
|
||||
*a = song_list;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct QueueWidget;
|
||||
|
||||
impl StatefulWidget for QueueWidget {
|
||||
type State = Queue;
|
||||
|
||||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||||
if !state.songs_list.lock().unwrap().is_empty() {
|
||||
let vertical_pad = |line| Text::from(vec!["".into(), line, "".into()]);
|
||||
let header_cells = ["Title", "Artist", "Duration"]
|
||||
.map(|h| h.bold().into())
|
||||
.map(vertical_pad);
|
||||
let header = Row::new(header_cells).height(3);
|
||||
|
||||
let column_widths = [Max(20), Max(20), Max(6)];
|
||||
|
||||
let list = state.songs_list.lock().unwrap();
|
||||
let rows = list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, song)| row_from_song(song))
|
||||
.collect_vec();
|
||||
|
||||
let table = Table::new(rows, column_widths)
|
||||
.header(header)
|
||||
.column_spacing(3)
|
||||
.highlight_spacing(HighlightSpacing::Always)
|
||||
// The selected row, column, cell and its content can also be styled.
|
||||
.row_highlight_style(Style::new().reversed())
|
||||
.column_highlight_style(Style::new().red())
|
||||
.cell_highlight_style(Style::new().blue())
|
||||
// ...and potentially show a symbol in front of the selection.
|
||||
.highlight_symbol(">>");
|
||||
|
||||
StatefulWidget::render(table, area, buf, &mut state.table_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row_from_song(song: &Child) -> Row {
|
||||
let duration = song.duration.unwrap().to_duration().as_secs();
|
||||
Row::new([
|
||||
Text::from(song.title.clone()),
|
||||
Text::from(song.artist.clone().unwrap()),
|
||||
Text::from(format!("{:0>2}:{:0>2}", duration / 60, duration % 60)),
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user