feat: implement harmony TUI with vim/evil-mode navigation and SPC leader
Full Ratatui implementation of the harmony music library manager prototype: - 6 tab views (Library 3-pane, Wanted, Queue, History, Calendar, Settings) - Vim/evil-mode keybindings (hjkl, counts, gg/G, w/b/e, Ctrl-d/u, H/M/L, marks, operator-pending) - SPC leader key with which-key popup (Doom Emacs style) - Command mode (:q, :theme, :help) and / search filter - Help and quit confirmation modals - Toast notification system with auto-dismiss - Gruvbox dark theme throughout
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{List, ListItem, ListState, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::data::{Album, AlbumStatus, Artist, Track};
|
||||
use crate::theme;
|
||||
use crate::ui::pane::{section_divider, Pane};
|
||||
use crate::ui::progress_bar::progress_bar;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LibraryFocus {
|
||||
#[default]
|
||||
Artists,
|
||||
Albums,
|
||||
Tracks,
|
||||
}
|
||||
|
||||
pub struct LibraryState {
|
||||
pub artists: Vec<Artist>,
|
||||
pub focus: LibraryFocus,
|
||||
pub artist_state: ListState,
|
||||
pub album_state: ListState,
|
||||
pub track_state: ListState,
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
pub fn new(artists: Vec<Artist>) -> Self {
|
||||
let mut artist_state = ListState::default();
|
||||
let mut album_state = ListState::default();
|
||||
let mut track_state = ListState::default();
|
||||
|
||||
if !artists.is_empty() {
|
||||
artist_state.select(Some(0));
|
||||
if !artists[0].albums.is_empty() {
|
||||
album_state.select(Some(0));
|
||||
track_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
artists,
|
||||
focus: LibraryFocus::Artists,
|
||||
artist_state,
|
||||
album_state,
|
||||
track_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_artist(&self) -> Option<&Artist> {
|
||||
self.artist_state.selected().and_then(|i| self.artists.get(i))
|
||||
}
|
||||
|
||||
pub fn selected_album(&self) -> Option<&Album> {
|
||||
self.selected_artist()
|
||||
.and_then(|a| self.album_state.selected().and_then(|i| a.albums.get(i)))
|
||||
}
|
||||
|
||||
pub fn move_up(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
if let Some(i) = self.artist_state.selected() {
|
||||
if i > 0 {
|
||||
self.artist_state.select(Some(i - 1));
|
||||
self.reset_album_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
if let Some(i) = self.album_state.selected() {
|
||||
if i > 0 {
|
||||
self.album_state.select(Some(i - 1));
|
||||
self.reset_track_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {
|
||||
if let Some(i) = self.track_state.selected() {
|
||||
if i > 0 {
|
||||
self.track_state.select(Some(i - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_down(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
let max = self.artists.len().saturating_sub(1);
|
||||
if let Some(i) = self.artist_state.selected() {
|
||||
if i < max {
|
||||
self.artist_state.select(Some(i + 1));
|
||||
self.reset_album_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
let max = self.selected_artist()
|
||||
.map(|a| a.albums.len().saturating_sub(1))
|
||||
.unwrap_or(0);
|
||||
if let Some(i) = self.album_state.selected() {
|
||||
if i < max {
|
||||
self.album_state.select(Some(i + 1));
|
||||
self.reset_track_selection();
|
||||
}
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {
|
||||
let max = self.track_count().saturating_sub(1);
|
||||
if let Some(i) = self.track_state.selected() {
|
||||
if i < max {
|
||||
self.track_state.select(Some(i + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn track_count(&self) -> usize {
|
||||
self.selected_album().map(|a| a.total as usize).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn focus_left(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {}
|
||||
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
|
||||
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn focus_right(&mut self) {
|
||||
match self.focus {
|
||||
LibraryFocus::Artists => {
|
||||
if self.selected_artist().is_some() {
|
||||
self.focus = LibraryFocus::Albums;
|
||||
}
|
||||
}
|
||||
LibraryFocus::Albums => {
|
||||
if self.selected_album().is_some() {
|
||||
self.focus = LibraryFocus::Tracks;
|
||||
}
|
||||
}
|
||||
LibraryFocus::Tracks => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
LibraryFocus::Artists => LibraryFocus::Albums,
|
||||
LibraryFocus::Albums => LibraryFocus::Tracks,
|
||||
LibraryFocus::Tracks => LibraryFocus::Artists,
|
||||
};
|
||||
}
|
||||
|
||||
fn reset_album_selection(&mut self) {
|
||||
if let Some(artist) = self.selected_artist() {
|
||||
if !artist.albums.is_empty() {
|
||||
self.album_state.select(Some(0));
|
||||
} else {
|
||||
self.album_state.select(None);
|
||||
}
|
||||
}
|
||||
self.reset_track_selection();
|
||||
}
|
||||
|
||||
fn reset_track_selection(&mut self) {
|
||||
if self.selected_album().is_some() {
|
||||
self.track_state.select(Some(0));
|
||||
} else {
|
||||
self.track_state.select(None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn artist_count(&self) -> usize {
|
||||
self.artists.len()
|
||||
}
|
||||
|
||||
pub fn selected_artist_index(&self) -> Option<usize> {
|
||||
self.artist_state.selected()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
|
||||
if !monitored {
|
||||
return ('◌', Style::default().fg(theme::GRAY));
|
||||
}
|
||||
match status {
|
||||
AlbumStatus::Complete => ('●', Style::default().fg(theme::GREEN)),
|
||||
AlbumStatus::Partial => ('◐', Style::default().fg(theme::YELLOW)),
|
||||
AlbumStatus::Wanted => ('○', Style::default().fg(theme::RED)),
|
||||
AlbumStatus::Unmonitored => ('◌', Style::default().fg(theme::GRAY)),
|
||||
}
|
||||
}
|
||||
|
||||
fn track_icon(have: bool) -> (char, Style) {
|
||||
if have {
|
||||
('✓', Style::default().fg(theme::GREEN))
|
||||
} else {
|
||||
('✗', Style::default().fg(theme::RED))
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_status(artist: &Artist) -> AlbumStatus {
|
||||
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
||||
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
||||
if have == total {
|
||||
AlbumStatus::Complete
|
||||
} else if have == 0 {
|
||||
AlbumStatus::Wanted
|
||||
} else {
|
||||
AlbumStatus::Partial
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_size(gb: f64) -> String {
|
||||
if gb >= 1.0 {
|
||||
format!("{:.1} GB", gb)
|
||||
} else {
|
||||
format!("{} MB", (gb * 1024.0).round() as u32)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
let chunks = Layout::horizontal([Constraint::Length(32), Constraint::Fill(1)]).split(area);
|
||||
|
||||
render_artists_pane(frame, chunks[0], state);
|
||||
render_detail_pane(frame, chunks[1], state);
|
||||
}
|
||||
|
||||
fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
let focused = state.focus == LibraryFocus::Artists;
|
||||
let artist_count = state.artists.len();
|
||||
|
||||
let total_albums: usize = state.artists.iter().map(|a| a.albums.len()).sum();
|
||||
let total_size: f64 = state.artists.iter().map(|a| a.size_gb).sum();
|
||||
|
||||
let footer = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} artists · {} alb", artist_count, total_albums),
|
||||
Style::default().fg(theme::GRAY),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(fmt_size(total_size), Style::default().fg(theme::GRAY)),
|
||||
]);
|
||||
|
||||
let artist_count_str = artist_count.to_string();
|
||||
let pane = Pane::new("Artists")
|
||||
.meta(&artist_count_str)
|
||||
.focused(focused)
|
||||
.footer(footer);
|
||||
|
||||
let block = pane.build_block();
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| {
|
||||
let status = artist_status(artist);
|
||||
let (icon_char, icon_style) = status_icon(status, artist.monitored);
|
||||
|
||||
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
||||
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
||||
|
||||
let mut name_text = artist.name.clone();
|
||||
if !artist.monitored {
|
||||
name_text.push_str(" ·unm");
|
||||
}
|
||||
|
||||
let count_str = format!("{}/{}", have, total);
|
||||
let name_width = inner.width as usize - 2 - count_str.len() - 2;
|
||||
if name_text.len() > name_width {
|
||||
name_text.truncate(name_width.saturating_sub(1));
|
||||
name_text.push('…');
|
||||
}
|
||||
|
||||
let padding = name_width.saturating_sub(name_text.len());
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", icon_char), icon_style),
|
||||
Span::styled(name_text, Style::default().fg(theme::FG1)),
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(count_str, Style::default().fg(theme::GRAY)),
|
||||
])
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let highlight_style = if focused {
|
||||
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
||||
} else {
|
||||
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
||||
};
|
||||
|
||||
let list = List::new(items).highlight_style(highlight_style);
|
||||
frame.render_stateful_widget(list, inner, &mut state.artist_state);
|
||||
}
|
||||
|
||||
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
|
||||
|
||||
let artist = state.selected_artist();
|
||||
|
||||
let meta = artist
|
||||
.map(|a| format!("{} · {}", a.country, a.genres.first().map(|s| s.as_str()).unwrap_or("")))
|
||||
.unwrap_or_default();
|
||||
|
||||
let have_tracks: u16 = artist
|
||||
.map(|a| a.albums.iter().map(|al| al.have).sum())
|
||||
.unwrap_or(0);
|
||||
let total_tracks: u16 = artist
|
||||
.map(|a| a.albums.iter().map(|al| al.total).sum())
|
||||
.unwrap_or(0);
|
||||
|
||||
let footer = Line::from(vec![
|
||||
Span::styled("[a]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" add ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[m]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" monitor ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[s]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" search ", Style::default().fg(theme::FG2)),
|
||||
Span::styled("[r]", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(" refresh", Style::default().fg(theme::FG2)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{}/{} tracks", have_tracks, total_tracks),
|
||||
Style::default().fg(theme::GRAY),
|
||||
),
|
||||
]);
|
||||
|
||||
let pane = Pane::new("Detail")
|
||||
.meta(&meta)
|
||||
.focused(focused)
|
||||
.footer(footer);
|
||||
|
||||
let block = pane.build_block();
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(6),
|
||||
Constraint::Length(1),
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
if let Some(artist) = artist {
|
||||
render_artist_header(frame, chunks[0], artist);
|
||||
}
|
||||
|
||||
let albums_count = artist.map(|a| a.albums.len()).unwrap_or(0);
|
||||
let albums_label = format!("{} releases", albums_count);
|
||||
let album_divider = section_divider("albums", Some(&albums_label));
|
||||
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
|
||||
|
||||
let selected_artist_idx = state.artist_state.selected();
|
||||
if let Some(idx) = selected_artist_idx {
|
||||
if let Some(artist) = state.artists.get(idx) {
|
||||
let albums = artist.albums.clone();
|
||||
let focus = state.focus;
|
||||
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
|
||||
}
|
||||
}
|
||||
|
||||
let album_title = state
|
||||
.selected_album()
|
||||
.map(|a| a.title.clone())
|
||||
.unwrap_or_default();
|
||||
let track_counts = state
|
||||
.selected_album()
|
||||
.map(|a| format!("{}/{}", a.have, a.total))
|
||||
.unwrap_or_default();
|
||||
let track_label = format!("tracks · {}", album_title);
|
||||
let track_divider = section_divider(&track_label, Some(&track_counts));
|
||||
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
|
||||
|
||||
render_tracks_list(frame, chunks[4], state);
|
||||
}
|
||||
|
||||
fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
|
||||
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
||||
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
|
||||
|
||||
let (status_icon, status_text, status_style) = if artist.monitored {
|
||||
(
|
||||
Span::styled("● ", Style::default().fg(theme::GREEN)),
|
||||
"Monitored",
|
||||
Style::default().fg(theme::FG2),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Span::styled("◌ ", Style::default().fg(theme::GRAY)),
|
||||
"Unmonitored",
|
||||
Style::default().fg(theme::GRAY),
|
||||
)
|
||||
};
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
&artist.name,
|
||||
Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled("status ", Style::default().fg(theme::GRAY)),
|
||||
status_icon,
|
||||
Span::styled(status_text, status_style),
|
||||
Span::raw(" "),
|
||||
Span::styled("path ", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(&artist.path, Style::default().fg(theme::AQUA)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("quality ", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(&artist.quality, Style::default().fg(theme::FG1)),
|
||||
Span::raw(" "),
|
||||
Span::styled("size ", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(fmt_size(artist.size_gb), Style::default().fg(theme::FG1)),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("albums ", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(artist.albums.len().to_string(), Style::default().fg(theme::FG1)),
|
||||
Span::raw(" "),
|
||||
Span::styled("tracks ", Style::default().fg(theme::GRAY)),
|
||||
Span::styled(have.to_string(), Style::default().fg(theme::FG1)),
|
||||
Span::styled(format!(" / {}", total), Style::default().fg(theme::GRAY)),
|
||||
]),
|
||||
];
|
||||
|
||||
let paragraph = Paragraph::new(lines).style(Style::default().bg(theme::BG0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn render_albums_list(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
albums: &[Album],
|
||||
focus: LibraryFocus,
|
||||
album_state: &mut ListState,
|
||||
) {
|
||||
let focused = focus == LibraryFocus::Albums;
|
||||
|
||||
let items: Vec<ListItem> = albums
|
||||
.iter()
|
||||
.map(|album| {
|
||||
let (icon_char, icon_style) = status_icon(album.status, album.monitored);
|
||||
let type_str = format!("[{}]", album.album_type);
|
||||
let year_str = album.year.to_string();
|
||||
let progress = progress_bar(album.have, album.total, 10, album.status);
|
||||
let count_str = format!("{}/{}", album.have, album.total);
|
||||
|
||||
let quality_style = if album.quality == "—" {
|
||||
Style::default().fg(theme::GRAY)
|
||||
} else {
|
||||
Style::default().fg(theme::AQUA)
|
||||
};
|
||||
|
||||
let title_width = area.width as usize - 2 - type_str.len() - 1 - 4 - 1 - 10 - 1 - 5 - 1 - 8;
|
||||
let mut title = album.title.clone();
|
||||
if title.len() > title_width {
|
||||
title.truncate(title_width.saturating_sub(1));
|
||||
title.push('…');
|
||||
}
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(format!("{} ", icon_char), icon_style),
|
||||
Span::styled(title, Style::default().fg(theme::FG1)),
|
||||
Span::raw(" "),
|
||||
Span::styled(type_str, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(year_str, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
];
|
||||
spans.extend(progress.spans);
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(count_str, Style::default().fg(theme::GRAY)));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(&album.quality, quality_style));
|
||||
|
||||
Line::from(spans).into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let highlight_style = if focused {
|
||||
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
||||
} else {
|
||||
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
||||
};
|
||||
|
||||
let list = List::new(items).highlight_style(highlight_style);
|
||||
frame.render_stateful_widget(list, area, album_state);
|
||||
}
|
||||
|
||||
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||
let focused = state.focus == LibraryFocus::Tracks;
|
||||
let tracks = state.get_tracks();
|
||||
|
||||
if tracks.is_empty() {
|
||||
let msg = Paragraph::new(Span::styled(
|
||||
"(no album selected)",
|
||||
Style::default().fg(theme::GRAY),
|
||||
));
|
||||
frame.render_widget(msg, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = tracks
|
||||
.iter()
|
||||
.map(|track| {
|
||||
let (icon_char, icon_style) = track_icon(track.have);
|
||||
let num_str = format!("{:02}", track.number);
|
||||
|
||||
let title_style = if track.have {
|
||||
Style::default().fg(theme::FG1)
|
||||
} else {
|
||||
Style::default().fg(theme::GRAY)
|
||||
};
|
||||
|
||||
let quality_style = if track.have {
|
||||
Style::default().fg(theme::AQUA)
|
||||
} else {
|
||||
Style::default().fg(theme::RED)
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled(format!("{} ", icon_char), icon_style),
|
||||
Span::styled(num_str, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.title, title_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.duration, Style::default().fg(theme::GRAY)),
|
||||
Span::raw(" "),
|
||||
Span::styled(&track.quality, quality_style),
|
||||
])
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let highlight_style = if focused {
|
||||
Style::default().bg(theme::YELLOW).fg(theme::BG0)
|
||||
} else {
|
||||
Style::default().bg(theme::SELECT_BG).fg(theme::FG1)
|
||||
};
|
||||
|
||||
let list = List::new(items).highlight_style(highlight_style);
|
||||
frame.render_stateful_widget(list, area, &mut state.track_state);
|
||||
}
|
||||
|
||||
impl LibraryState {
|
||||
pub fn get_tracks(&self) -> Vec<Track> {
|
||||
let Some(album) = self.selected_album() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if album.id == "bush" {
|
||||
return crate::data::sample_tracks_bush_hall();
|
||||
}
|
||||
|
||||
tracks_for(album)
|
||||
}
|
||||
}
|
||||
|
||||
fn tracks_for(album: &Album) -> Vec<Track> {
|
||||
let titles = [
|
||||
"Opening", "Curtain Call", "Half-Light", "Polaroid", "Switchback",
|
||||
"Slow Dancer", "The Inheritance", "Glassworks", "Interlude", "Aftermath",
|
||||
"Static", "Returner", "Dust Bowl", "Postcard", "Late Reply",
|
||||
"Honeymoon", "Northern Lights", "Cold Open", "Coda", "Reprise",
|
||||
];
|
||||
|
||||
(0..album.total)
|
||||
.map(|i| {
|
||||
let idx = i as usize;
|
||||
let m = 2 + ((idx * 7) % 5);
|
||||
let s = (idx * 13) % 60;
|
||||
let title_idx = idx % titles.len();
|
||||
let title = if idx >= titles.len() {
|
||||
format!("{} II", titles[title_idx])
|
||||
} else {
|
||||
titles[title_idx].to_string()
|
||||
};
|
||||
let have = i < album.have;
|
||||
let quality = if have {
|
||||
album.quality.clone()
|
||||
} else {
|
||||
"—".to_string()
|
||||
};
|
||||
|
||||
Track {
|
||||
number: i + 1,
|
||||
title,
|
||||
duration: format!("{}:{:02}", m, s),
|
||||
have,
|
||||
quality,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Reference in New Issue
Block a user