mod theme; use anyhow::Result; use crossterm::event::{Event, EventStream, KeyCode, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use futures_util::StreamExt; use image::DynamicImage; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{ Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, }; use ratatui::Terminal; use ratatui_image::picker::Picker; use ratatui_image::protocol::StatefulProtocol; use ratatui_image::StatefulImage; use riptune_cache::Cache; use riptune_core::models::{AlbumSummary, Artist, Track}; use riptune_core::SubsonicClient; use riptune_types::{AudioCommand, AudioEvent}; use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{Receiver, Sender}; use std::time::Duration; #[derive(Clone, Copy, PartialEq)] enum FocusPane { Artists, Albums, Tracks, } #[derive(Clone, Copy, PartialEq)] enum SearchScope { Artist, Album, Song, } #[derive(Clone, Copy, PartialEq)] enum RepeatMode { Off, One, All, } #[derive(PartialEq)] pub enum PlaybackStatus { Buffering, Playing, Paused, Stopped, Error(String), } enum InputMode { Normal, Search, } struct NowPlaying { title: String, artist: String, album: String, position: Duration, duration_secs: Option, status: PlaybackStatus, current_track_id: String, playlist_context: Vec, cover_art_id: Option, } pub struct App { client: SubsonicClient, cache: Cache, audio_tx: Sender, audio_events: Receiver, // Library Views (Cached Locally)[cite: 7] artists: Vec, albums: Vec, tracks: Vec, // Dynamic Navigation States[cite: 7] focus: FocusPane, selected_artist: Option, selected_album: Option, // Core States selected_idx: usize, now_playing: Option, status_line: Option, volume: f32, input_mode: InputMode, search_query: String, search_scope: SearchScope, show_help: bool, // Playback Engine Mods shuffle: bool, repeat: RepeatMode, // List scroll state, persisted across frames (see `draw`) so the // viewport doesn't reset to the top on every render. artists_state: ListState, albums_state: ListState, tracks_state: ListState, // Cover art. `cover_picker` is None if the terminal doesn't support (or // detection of) an image protocol failed -- the art panel is then just // skipped rather than erroring. Fetches run on the tokio runtime and // report back over `cover_art_rx`; `cover_in_flight` stops the same id // from being fetched twice while a request is outstanding. cover_picker: Option, cover_cache: HashMap>, cover_in_flight: HashSet, cover_art_tx: tokio::sync::mpsc::UnboundedSender<(String, Option)>, cover_art_rx: tokio::sync::mpsc::UnboundedReceiver<(String, Option)>, theme: theme::Theme, } struct TerminalCleanup; impl Drop for TerminalCleanup { fn drop(&mut self) { let _ = disable_raw_mode(); let _ = execute!(std::io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); } } impl App { pub fn new( client: SubsonicClient, cache: Cache, audio_tx: Sender, audio_events: Receiver, theme_colors: riptune_types::ThemeColors, ) -> Self { let (cover_art_tx, cover_art_rx) = tokio::sync::mpsc::unbounded_channel(); Self { client, cache, audio_tx, audio_events, artists: Vec::new(), albums: Vec::new(), tracks: Vec::new(), focus: FocusPane::Artists, selected_artist: None, selected_album: None, selected_idx: 0, now_playing: None, status_line: Some("Initializing Offline Cache...".into()), volume: 0.8, input_mode: InputMode::Normal, search_query: String::new(), search_scope: SearchScope::Song, show_help: false, shuffle: false, repeat: RepeatMode::Off, artists_state: ListState::default(), albums_state: ListState::default(), tracks_state: ListState::default(), cover_picker: None, cover_cache: HashMap::new(), cover_in_flight: HashSet::new(), cover_art_tx, cover_art_rx, theme: theme::Theme::from_config(&theme_colors), } } pub async fn run(mut self) -> Result<()> { enable_raw_mode()?; // Best-effort: query the terminal for an image graphics protocol // (Kitty/iTerm2/Sixel, falling back to unicode half-blocks). If // detection fails outright, cover art is simply not shown. self.cover_picker = Picker::from_termios() .ok() .map(|mut picker| { picker.guess_protocol(); picker }); let mut stdout = std::io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let _cleanup_guard = TerminalCleanup; let result = self.event_loop(&mut terminal).await; // Explicit cleanup before returning disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; result } async fn event_loop(&mut self, terminal: &mut Terminal>) -> Result<()> { if let Ok(records) = self.cache.search_local_artists("").await { self.artists = records; self.status_line = None; } let mut input = EventStream::new(); let mut ticker = tokio::time::interval(Duration::from_millis(200)); loop { while let Ok(event) = self.audio_events.try_recv() { self.apply_audio_event(event); } terminal.draw(|f| self.draw(f))?; tokio::select! { maybe_event = input.next() => { match maybe_event { Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => { if self.handle_key(key.code).await { break; } } Some(Err(e)) => { self.status_line = Some(format!("input error: {e}")); } None => break, _ => {} } } _ = ticker.tick() => {} Some((cover_id, image)) = self.cover_art_rx.recv() => { self.cover_in_flight.remove(&cover_id); if let (Some(image), Some(picker)) = (image, &mut self.cover_picker) { self.cover_cache.insert(cover_id, picker.new_resize_protocol(image)); } } } } Ok(()) } async fn handle_key(&mut self, code: KeyCode) -> bool { match self.input_mode { InputMode::Normal => match code { KeyCode::Char('q') => return true, KeyCode::Char('?') => self.show_help = !self.show_help, KeyCode::Char('/') => { if !self.show_help { self.input_mode = InputMode::Search; } } // Shuffle & Repeat Controls KeyCode::Char('s') => { self.shuffle = !self.shuffle; self.notify_desktop_environment("Shuffle Toggled"); } KeyCode::Char('r') => { self.repeat = match self.repeat { RepeatMode::Off => RepeatMode::All, RepeatMode::All => RepeatMode::One, RepeatMode::One => RepeatMode::Off, }; self.notify_desktop_environment("Repeat Mode Changed"); } KeyCode::Esc | KeyCode::Backspace => self.go_back().await, KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Left | KeyCode::Char('h') => self.move_pane(-1).await, KeyCode::Right | KeyCode::Char('l') | KeyCode::Enter => self.activate_selection().await, KeyCode::Char(' ') => self.toggle_play_pause(), KeyCode::Char(']') | KeyCode::Char('+') => { self.volume = (self.volume + 0.05).min(1.0); let _ = self.audio_tx.send(AudioCommand::SetVolume(self.volume)); } KeyCode::Char('[') | KeyCode::Char('-') => { self.volume = (self.volume - 0.05).max(0.0); let _ = self.audio_tx.send(AudioCommand::SetVolume(self.volume)); } _ => {} }, InputMode::Search => match code { KeyCode::Esc | KeyCode::Enter => { self.input_mode = InputMode::Normal; } KeyCode::Tab => { self.search_scope = match self.search_scope { SearchScope::Song => SearchScope::Artist, SearchScope::Artist => SearchScope::Album, SearchScope::Album => SearchScope::Song, }; let query = self.search_query.clone(); self.execute_db_search(&query).await; } KeyCode::Char(c) => { self.search_query.push(c); let query = self.search_query.clone(); self.execute_db_search(&query).await; } KeyCode::Backspace => { self.search_query.pop(); let query = self.search_query.clone(); self.execute_db_search(&query).await; } _ => {} }, } false } // --- Option 2 Integration Hooks (MPRIS & Niri IPC Sync Channels) --- fn notify_desktop_environment(&self, action_context: &str) { // These hooks act as communication bridges for your riptune-mpris and riptune-niri subcrates[cite: 1] // Example: Emits state logs that zbus / mpris handlers catch via standard asynchronous orchestration[cite: 1, 7] if let Some(np) = &self.now_playing { let _status_indicator = match np.status { PlaybackStatus::Playing => "Playing", PlaybackStatus::Paused => "Paused", _ => "Stopped", }; // Under-the-hood bindings read this payload state to dynamically broadcast D-Bus properties[cite: 1, 7] std::string::ToString::to_string(&format!("MPRIS/NIRI Sync Triggered: {} -> {} ({})", action_context, np.title, _status_indicator)); } } async fn execute_db_search(&mut self, query: &str) { self.selected_idx = 0; // If the user clears the search query, reset the panels to show everything parsed so far if query.is_empty() { if let Ok(records) = self.cache.search_local_artists("").await { self.artists = records; } self.albums.clear(); self.tracks.clear(); self.focus = FocusPane::Artists; return; } match self.search_scope { SearchScope::Artist => { if let Ok(records) = self.cache.search_local_artists(query).await { self.artists = records; self.focus = FocusPane::Artists; } } SearchScope::Album => { if let Ok(records) = self.cache.search_local_albums(query).await { self.albums = records; self.focus = FocusPane::Albums; } } SearchScope::Song => { // FIXED: Global song searches fill the right-hand Tracks window directly! if let Ok(records) = self.cache.search_local_tracks(query).await { self.tracks = records; self.focus = FocusPane::Tracks; } } } } async fn move_pane(&mut self, direction: i32) { self.selected_idx = 0; if direction > 0 { self.focus = match self.focus { FocusPane::Artists => FocusPane::Albums, FocusPane::Albums => FocusPane::Tracks, FocusPane::Tracks => FocusPane::Tracks, }; } else { self.focus = match self.focus { FocusPane::Artists => FocusPane::Artists, FocusPane::Albums => FocusPane::Artists, FocusPane::Tracks => FocusPane::Albums, }; } } async fn go_back(&mut self) { self.search_query.clear(); self.move_pane(-1).await; } fn current_len(&self) -> usize { match self.focus { FocusPane::Artists => self.artists.len(), FocusPane::Albums => self.albums.len(), FocusPane::Tracks => self.tracks.len(), } } fn move_selection(&mut self, delta: i32) { let len = self.current_len(); if len == 0 { return; } let next = (self.selected_idx as i32 + delta).rem_euclid(len as i32); self.selected_idx = next as usize; } async fn activate_selection(&mut self) { match self.focus { FocusPane::Artists => { if let Some(artist) = self.artists.get(self.selected_idx).cloned() { self.selected_artist = Some(artist.clone()); if let Ok(records) = self.cache.get_albums_by_artist(&artist.id).await { self.albums = records; self.focus = FocusPane::Albums; self.selected_idx = 0; } } } FocusPane::Albums => { if let Some(album) = self.albums.get(self.selected_idx).cloned() { self.selected_album = Some(album.clone()); if let Ok(records) = self.cache.get_tracks_by_album(&album.name).await { self.tracks = records; self.focus = FocusPane::Tracks; self.selected_idx = 0; } } } FocusPane::Tracks => { if let Some(track) = self.tracks.get(self.selected_idx).cloned() { self.play_track(track.clone(), self.tracks.clone()); } } } } fn play_track(&mut self, track: Track, playlist_context: Vec) { match self.client.stream_url(&track.id) { Ok(stream_url) => { let _ = self.audio_tx.send(AudioCommand::Play { stream_url }); if let Some(cover_id) = &track.cover_art { self.request_cover_art(cover_id.clone()); } self.now_playing = Some(NowPlaying { title: track.title.clone(), artist: track.artist.clone().unwrap_or_default(), album: track.album.clone().unwrap_or_default(), position: Duration::ZERO, duration_secs: track.duration_secs, status: PlaybackStatus::Buffering, current_track_id: track.id.clone(), playlist_context, cover_art_id: track.cover_art.clone(), }); self.notify_desktop_environment("Track Started"); } Err(e) => { self.status_line = Some(format!("failed to build stream url: {e}")); } } } /// Kicks off a background fetch+decode for a cover art id, unless it's /// already cached or a fetch for it is already in flight. Errors are /// swallowed -- missing art just means the panel stays empty. fn request_cover_art(&mut self, cover_id: String) { if self.cover_picker.is_none() || self.cover_cache.contains_key(&cover_id) || self.cover_in_flight.contains(&cover_id) { return; } self.cover_in_flight.insert(cover_id.clone()); let client = self.client.clone(); let tx = self.cover_art_tx.clone(); tokio::spawn(async move { let image = match client.get_cover_art(&cover_id).await { Ok(bytes) => image::load_from_memory(&bytes).ok(), Err(_) => None, }; let _ = tx.send((cover_id, image)); }); } fn toggle_play_pause(&mut self) { if let Some(now_playing) = &mut self.now_playing { match &now_playing.status { PlaybackStatus::Playing => { let _ = self.audio_tx.send(AudioCommand::Pause); now_playing.status = PlaybackStatus::Paused; } PlaybackStatus::Paused => { let _ = self.audio_tx.send(AudioCommand::Resume); now_playing.status = PlaybackStatus::Playing; } _ => {} } self.notify_desktop_environment("Play/Pause Toggled"); } else if let Some(track) = self.tracks.get(self.selected_idx).cloned() { self.play_track(track, self.tracks.clone()); } } fn apply_audio_event(&mut self, event: AudioEvent) { match event { AudioEvent::Buffering => { if let Some(np) = &mut self.now_playing { np.status = PlaybackStatus::Buffering; } } AudioEvent::PositionChanged(pos) => { if let Some(np) = &mut self.now_playing { np.position = pos; np.status = PlaybackStatus::Playing; } } AudioEvent::TrackFinished => { if let Some(np) = self.now_playing.take() { let current_id = np.current_track_id; let context = np.playlist_context; if self.repeat == RepeatMode::One { if let Some(track) = context.iter().find(|t| t.id == current_id).cloned() { self.play_track(track, context); return; } } if self.shuffle && !context.is_empty() { // Clean fallback: Use a timestamp to pick a random index without needing the 'rand' crate dependency let pseudo_random_idx = (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_micros() as usize) % context.len(); if let Some(random_track) = context.get(pseudo_random_idx).cloned() { self.play_track(random_track, context); return; } } else if let Some(current_idx) = context.iter().position(|t| t.id == current_id) { let next_idx = current_idx + 1; if next_idx < context.len() { if let Some(next_track) = context.get(next_idx).cloned() { self.play_track(next_track, context); return; } } else if self.repeat == RepeatMode::All { if let Some(first_track) = context.first().cloned() { self.play_track(first_track, context); return; } } } } self.now_playing = None; self.notify_desktop_environment("Track Stopped/Finished"); } AudioEvent::Error(message) => { self.status_line = Some(format!("playback error: {message}")); } } } fn draw(&mut self, f: &mut ratatui::Frame) { let area = f.area(); // --- 1. Layout Resizing Shield --- // Guarantees zero terminal rendering crashes if constraint requirements fall below boundaries if area.width < 85 || area.height < 18 { let emergency_msg = Paragraph::new( "\n\n ⚠️ Terminal Window Too Small!\n ===============================\n Please enlarge your screen layout dimensions\n to display Riptune's multi-tier grids safely." ) .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(self.theme.error))); f.render_widget(emergency_msg, area); return; } let main_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(3), Constraint::Length(3), Constraint::Length(7), Constraint::Length(1), ]) .split(area); // Give the focused pane the room -- with a single artist and a // handful of albums, an equal three-way split leaves the two // breadcrumb panes mostly empty while Tracks (denser, especially // once titles carry track numbers + durations) is cramped. let (artists_pct, albums_pct, tracks_pct) = match self.focus { FocusPane::Artists => (50, 30, 20), FocusPane::Albums => (25, 50, 25), FocusPane::Tracks => (20, 20, 60), }; let columns_layout = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(artists_pct), Constraint::Percentage(albums_pct), Constraint::Percentage(tracks_pct), ]) .split(main_layout[0]); let now_playing_id = self.now_playing.as_ref().map(|np| np.current_track_id.as_str()); // Whether each pane's contents are currently the result of an // active search in that pane's scope -- drives both the "Matching: // N" title phrasing and per-row substring highlighting below. The // pane's list itself is already filtered to matches by // `execute_db_search`; this only affects how it's *labeled/shown*. let searching = matches!(self.input_mode, InputMode::Search) && !self.search_query.is_empty(); let searching_artists = searching && self.search_scope == SearchScope::Artist; let searching_albums = searching && self.search_scope == SearchScope::Album; let searching_tracks = searching && self.search_scope == SearchScope::Song; let match_style = Style::default().fg(self.theme.accent).add_modifier(Modifier::BOLD | Modifier::UNDERLINED); let artists_col_width = columns_layout[0].width.saturating_sub(4) as usize; let artists_list: Vec = self.artists.iter().map(|a| { let query = searching_artists.then_some(self.search_query.as_str()); let mut spans = vec![Span::raw(" ")]; spans.extend(highlighted_spans(&a.name, artists_col_width, query, Style::default(), match_style)); ListItem::new(Line::from(spans)) }).collect(); let albums_col_width = columns_layout[1].width.saturating_sub(4) as usize; let albums_list: Vec = self.albums.iter().map(|a| { let query = searching_albums.then_some(self.search_query.as_str()); let suffix = album_suffix(a); let name_budget = albums_col_width.saturating_sub(suffix.chars().count()).max(3); let mut spans = vec![Span::raw(" ")]; spans.extend(highlighted_spans(&a.name, name_budget, query, Style::default(), match_style)); spans.push(Span::styled(suffix, Style::default().fg(self.theme.text_muted))); ListItem::new(Line::from(spans)) }).collect(); // Duration right-aligned within the column, so it reads as a // consistent right rail rather than trailing the title unevenly. let tracks_col_width = columns_layout[2].width.saturating_sub(2) as usize; let tracks_list: Vec = self.tracks.iter().map(|t| { let is_now_playing = Some(t.id.as_str()) == now_playing_id; let icon = if is_now_playing { " \u{266a} " } else { " " }; let base_style = if is_now_playing { Style::default().fg(self.theme.now_playing).add_modifier(Modifier::BOLD) } else { Style::default() }; let duration = t.duration_secs.map(|d| format!("{}:{:02}", d / 60, d % 60)).unwrap_or_else(|| "--:--".into()); let title_budget = tracks_col_width.saturating_sub(icon.chars().count() + duration.chars().count() + 1).max(3); let query = searching_tracks.then_some(self.search_query.as_str()); let title_spans = highlighted_spans(&t.title, title_budget, query, base_style, match_style); let title_len: usize = title_spans.iter().map(|s| s.content.chars().count()).sum(); let mut spans = vec![Span::styled(icon, base_style)]; spans.extend(title_spans); let pad = tracks_col_width.saturating_sub(icon.chars().count() + title_len + duration.chars().count()).max(1); spans.push(Span::styled(" ".repeat(pad), base_style)); spans.push(Span::styled(duration, base_style)); ListItem::new(Line::from(spans)) }).collect(); let get_border_style = |pane: FocusPane| { if self.focus == pane { Style::default().fg(self.theme.border_focused) } else { Style::default().fg(self.theme.border_unfocused) } }; // A background band for the keyboard cursor -- kept distinct from // NOW_PLAYING's foreground-color-plus-icon treatment so the two // states stay legible whether they land on the same row or not. let cursor_highlight = Style::default().bg(self.theme.selection_bg).add_modifier(Modifier::BOLD); self.artists_state.select(if self.focus == FocusPane::Artists { Some(self.selected_idx) } else { None }); self.albums_state.select(if self.focus == FocusPane::Albums { Some(self.selected_idx) } else { None }); self.tracks_state.select(if self.focus == FocusPane::Tracks { Some(self.selected_idx) } else { None }); let pane_title = |label: &str, len: usize, matching: bool| { if matching { format!(" {label} (Matching: {len}) ") } else { format!(" {label} ({len}) ") } }; f.render_stateful_widget(List::new(artists_list).block(Block::default().borders(Borders::ALL).title(pane_title(":: Artists", self.artists.len(), searching_artists)).border_style(get_border_style(FocusPane::Artists))).highlight_style(cursor_highlight), columns_layout[0], &mut self.artists_state); f.render_stateful_widget(List::new(albums_list).block(Block::default().borders(Borders::ALL).title(pane_title("<> Albums", self.albums.len(), searching_albums)).border_style(get_border_style(FocusPane::Albums))).highlight_style(cursor_highlight), columns_layout[1], &mut self.albums_state); f.render_stateful_widget(List::new(tracks_list).block(Block::default().borders(Borders::ALL).title(pane_title(">> Tracks", self.tracks.len(), searching_tracks)).border_style(get_border_style(FocusPane::Tracks))).highlight_style(cursor_highlight), columns_layout[2], &mut self.tracks_state); render_scrollbar(f, columns_layout[0], self.artists.len(), self.artists_state.offset(), self.theme.border_unfocused); render_scrollbar(f, columns_layout[1], self.albums.len(), self.albums_state.offset(), self.theme.border_unfocused); render_scrollbar(f, columns_layout[2], self.tracks.len(), self.tracks_state.offset(), self.theme.border_unfocused); let search_border = match self.input_mode { InputMode::Search => self.theme.accent, InputMode::Normal => self.theme.border_unfocused, }; let (search_line, search_style) = match self.input_mode { InputMode::Search => { let scope_name = match self.search_scope { SearchScope::Artist => "Artist", SearchScope::Album => "Album", SearchScope::Song => "Song", }; let left = format!(" $ search: {}\u{2588}", self.search_query); let right = format!("[Target: {scope_name}] (TAB to cycle target) "); let width = main_layout[1].width.saturating_sub(2) as usize; let pad = width.saturating_sub(left.chars().count() + right.chars().count()).max(1); (format!("{left}{}{right}", " ".repeat(pad)), Style::default()) } InputMode::Normal => (" Press / to search".to_string(), Style::default().fg(self.theme.text_muted)), }; f.render_widget( Paragraph::new(search_line).style(search_style) .block(Block::default().borders(Borders::ALL).title(" Search ").border_style(Style::default().fg(search_border))), main_layout[1], ); // --- Now Playing deck: title/artist/album/progress/volume unified // into one panel instead of four separate blocks, so the elapsed // time, the bar, and the volume/shuffle/repeat state read as one // cohesive status rather than scattered fragments. let deck_block = Block::default().borders(Borders::ALL).title(" Now Playing "); let deck_inner = deck_block.inner(main_layout[2]); f.render_widget(deck_block, main_layout[2]); let cover_protocol = self.now_playing.as_ref() .and_then(|np| np.cover_art_id.as_ref()) .and_then(|id| self.cover_cache.get_mut(id)); let art_width = if cover_protocol.is_some() { 14 } else { 0 }; let deck_split = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(art_width), Constraint::Min(20)]) .split(deck_inner); if let Some(protocol) = cover_protocol { f.render_stateful_widget(StatefulImage::new(None), deck_split[0], protocol); } let title_line = if let Some(np) = &self.now_playing { Line::from(vec![ Span::styled(format!("{} ", playback_label(&np.status)), Style::default().fg(self.theme.accent)), Span::styled(np.title.clone(), Style::default().add_modifier(Modifier::BOLD)), ]) } else { Line::from(Span::styled( "Idle -- highlight a track and press Space or Enter", Style::default().fg(self.theme.text_muted), )) }; let artist_line = Line::from(format!("Artist: {}", self.now_playing.as_ref().map(|np| np.artist.as_str()).unwrap_or(""))); let album_line = Line::from(format!("Album: {}", self.now_playing.as_ref().map(|np| np.album.as_str()).unwrap_or(""))); let (elapsed_str, total_str, progress_ratio) = if let Some(np) = &self.now_playing { let elapsed = np.position.as_secs(); let total = np.duration_secs.map(|d| format!("{}:{:02}", d / 60, d % 60)).unwrap_or_else(|| "--:--".into()); let ratio = np.duration_secs .filter(|&d| d > 0) .map(|d| (np.position.as_secs() as f64 / d as f64).min(1.0)) .unwrap_or(0.0); (format!("{}:{:02}", elapsed / 60, elapsed % 60), total, ratio) } else { ("--:--".to_string(), "--:--".to_string(), 0.0) }; let text_width = deck_split[1].width as usize; let progress_bar_width = text_width .saturating_sub("Progress: [] ".len() + elapsed_str.len() + total_str.len() + 3) .clamp(10, 60); let progress_line = Line::from(vec![ Span::styled("Progress: ", Style::default().fg(self.theme.text_muted)), Span::raw("["), Span::styled(ascii_bar(progress_ratio, progress_bar_width, '=', '.', Some('>')), Style::default().fg(self.theme.progress)), Span::raw(format!("] {elapsed_str} / {total_str}")), ]); let shuffle_str = if self.shuffle { "ON" } else { "OFF" }; let repeat_str = match self.repeat { RepeatMode::Off => "OFF", RepeatMode::One => "TRACK", RepeatMode::All => "QUEUE", }; let volume_line = Line::from(vec![ Span::styled("Volume: \u{1f50a} ", Style::default().fg(self.theme.text_muted)), Span::styled(ascii_bar(self.volume as f64, 10, '\u{2588}', '\u{2591}', None), Style::default().fg(self.theme.volume)), Span::raw(format!( " {}% | Shuffle: {shuffle_str} Repeat: {repeat_str}", (self.volume * 100.0) as u16 )), ]); f.render_widget( Paragraph::new(vec![title_line, artist_line, album_line, progress_line, volume_line]), deck_split[1], ); // --- Hotkey legend: one line at the very bottom instead of hints // scattered across pane titles. let legend = " [Space] Play/Pause [/] Search [h/l] Pane [j/k] Move [s] Shuffle [r] Repeat [+/-] Vol [?] Help [q] Quit"; f.render_widget(Paragraph::new(legend).style(Style::default().fg(self.theme.text_muted)), main_layout[3]); if self.show_help { let help_text = r#" RIPTUNE NAVIGATION SHORTCUTS ======================================= [h] / [Left] : Focus left column [l] / [Right] : Focus right column / Expand directory [j] / [Down] : Move selection cursor down [k] / [Up] : Move selection cursor up [Enter] : Expand selection or Play track [Space] : Toggle Play / Pause state [Esc] : Clear query context or escape layout back [/] : Open universal search context box [TAB] : Cycle target filtering scope ([SONG], [ARTIST]) [s] : Toggle Shuffle playback order [r] : Cycle Repeat Mode (Off -> Queue -> Track) [-] / [[] : Decrease system volume output [+] / []] : Increase system volume output [?] : Toggle this floating instructions overview overlay [q] : Exit riptune application safely "#; let block = Block::default().title(" Help Overlay Menu ").borders(Borders::ALL).border_style(Style::default().fg(self.theme.help_border)); let paragraph = Paragraph::new(help_text).block(block); let popup_area = Rect { x: area.width / 4, y: area.height / 5, width: area.width / 2, height: 3 * area.height / 5, }; f.render_widget(Clear, popup_area); f.render_widget(paragraph, popup_area); } } } /// Renders a vertical scrollbar along the right edge of `area`, inset to sit /// inside a bordered block's margins. A no-op when everything already fits. fn render_scrollbar(f: &mut ratatui::Frame, area: Rect, content_len: usize, offset: usize, color: ratatui::style::Color) { if content_len <= area.height.saturating_sub(2) as usize { return; } let mut state = ScrollbarState::new(content_len).position(offset); let scrollbar = Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None) .style(Style::default().fg(color)); f.render_stateful_widget( scrollbar, area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }), &mut state, ); } /// Track count + year suffix for an album row (`" — 18 tracks, 2025"`), so /// near-identical entries (duplicate imports, case-variant folder names, /// etc.) are distinguishable before the user commits to opening one. fn album_suffix(album: &AlbumSummary) -> String { let tracks = format!("{} track{}", album.song_count, if album.song_count == 1 { "" } else { "s" }); match album.year { Some(year) => format!(" — {tracks}, {year}"), None => format!(" — {tracks}"), } } /// Renders a horizontal `[===>....]`-style bar (or solid blocks when `tip` /// is `None`, for the volume meter) instead of a filled rectangle, so it /// reads as a line-based indicator consistent with the rest of the UI /// rather than a clashing solid block of color. fn ascii_bar(ratio: f64, width: usize, filled_char: char, empty_char: char, tip: Option) -> String { if width == 0 { return String::new(); } let ratio = ratio.clamp(0.0, 1.0); let filled = ((ratio * width as f64).round() as usize).min(width); match tip { Some(tip_char) if filled > 0 && filled < width => { let body = filled_char.to_string().repeat(filled - 1); let rest = empty_char.to_string().repeat(width - filled); format!("{body}{tip_char}{rest}") } _ => { let body = filled_char.to_string().repeat(filled); let rest = empty_char.to_string().repeat(width - filled); format!("{body}{rest}") } } } /// Truncates to `max_chars`, appending an ellipsis when content was cut, /// instead of letting long names clip abruptly at the pane border. fn truncate_ellipsis(s: &str, max_chars: usize) -> String { if max_chars == 0 { return String::new(); } let count = s.chars().count(); if count <= max_chars { return s.to_string(); } if max_chars == 1 { return "\u{2026}".to_string(); } let truncated: String = s.chars().take(max_chars - 1).collect(); format!("{truncated}\u{2026}") } /// Truncates `text` to `budget` characters, then -- if `query` is active and /// found (case-insensitive) within the truncated text -- splits it into /// styled spans so the matched substring pops instead of leaving the reader /// to guess why a row matched. fn highlighted_spans(text: &str, budget: usize, query: Option<&str>, base: Style, match_style: Style) -> Vec> { let text = truncate_ellipsis(text, budget); let query = match query.filter(|q| !q.is_empty()) { Some(q) => q, None => return vec![Span::styled(text, base)], }; let chars: Vec = text.chars().collect(); let query_chars: Vec = query.chars().collect(); if query_chars.is_empty() || query_chars.len() > chars.len() { return vec![Span::styled(text, base)]; } let match_start = (0..=chars.len() - query_chars.len()).find(|&i| { chars[i..i + query_chars.len()] .iter() .zip(&query_chars) .all(|(a, b)| a.to_lowercase().eq(b.to_lowercase())) }); match match_start { Some(start) => { let before: String = chars[..start].iter().collect(); let matched: String = chars[start..start + query_chars.len()].iter().collect(); let after: String = chars[start + query_chars.len()..].iter().collect(); let mut spans = Vec::new(); if !before.is_empty() { spans.push(Span::styled(before, base)); } spans.push(Span::styled(matched, match_style)); if !after.is_empty() { spans.push(Span::styled(after, base)); } spans } None => vec![Span::styled(text, base)], } } fn playback_label(status: &PlaybackStatus) -> &str { match status { PlaybackStatus::Buffering => "[...] buffering...", PlaybackStatus::Playing => "[>] playing", PlaybackStatus::Paused => "[||] paused", PlaybackStatus::Stopped => "[.] stopped", PlaybackStatus::Error(_) => "[X] error", } }