use crate::audio::AudioPlayer;
use crate::search::{SearchEngine, SearchResult};
use crate::storage::{DataStore, ThemeColor, TrackItem, VisualStyle};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::Color;
use std::time::Instant;
#[derive(PartialEq, Clone, Copy, Debug)]
pub enum RepeatMode {
Off,
All,
One,
}
impl RepeatMode {
pub fn name(&self) -> &'static str {
match self {
RepeatMode::Off => "off",
RepeatMode::All => "all",
RepeatMode::One => "one",
}
}
}
#[derive(PartialEq, Clone, Copy, Debug)]
pub enum ViewMode {
Search,
Queue,
Playlists,
PlaylistDetail,
Favorites,
History,
UserProfile,
Lyrics,
Settings,
}
impl ViewMode {
pub fn name(&self) -> &'static str {
match self {
ViewMode::Search => "search",
ViewMode::Queue => "queue",
ViewMode::Playlists => "playlists",
ViewMode::PlaylistDetail => "playlist",
ViewMode::Favorites => "favorites",
ViewMode::History => "history",
ViewMode::UserProfile => "profile",
ViewMode::Lyrics => "lyrics",
ViewMode::Settings => "settings",
}
}
}
#[derive(Debug)]
pub enum StreamResolveEvent {
Resolved {
track_id: String,
url: String,
source: &'static str,
},
Failed {
track_id: String,
title: String,
},
}
pub struct App {
pub view_mode: ViewMode,
pub query_chars: Vec<char>,
pub input_cursor: usize,
pub is_searching: bool,
pub is_editing_search: bool,
pub search_results: Vec<TrackItem>,
pub search_cursor: usize,
pub queue: Vec<TrackItem>,
pub queue_cursor: usize,
pub current_track_idx: Option<usize>,
pub playlists_cursor: usize,
pub selected_playlist_idx: Option<usize>,
pub playlist_detail_cursor: usize,
pub history_cursor: usize,
pub favorites_cursor: usize,
pub settings_cursor: usize,
pub current_user_profile: Option<crate::search::UserProfile>,
pub user_profile_cursor: usize,
pub modal_input: Option<Vec<char>>,
pub modal_target_track: Option<TrackItem>,
pub modal_playlist_cursor: usize,
pub player: AudioPlayer,
pub search_engine: SearchEngine,
pub discord: crate::discord::DiscordRpc,
pub mpris: crate::mpris::MprisManager,
pub lyrics_manager: crate::lyrics::LyricsManager,
pub current_lyrics: Option<crate::lyrics::TrackLyrics>,
pub store: DataStore,
pub is_paused: bool,
pub current_pos: f64,
pub total_duration: f64,
pub volume: u32,
pub shuffle: bool,
pub repeat: RepeatMode,
pub prefetched_track_id: Option<String>,
pub current_waveform: Vec<u8>,
pub loaded_waveform_track_id: Option<String>,
pub stream_resolve_tx: std::sync::mpsc::Sender<StreamResolveEvent>,
pub stream_resolve_rx: std::sync::mpsc::Receiver<StreamResolveEvent>,
pub status_message: Option<(String, Instant)>,
pub last_esc_press: Option<Instant>,
pub last_rpc_update: Option<Instant>,
}
impl App {
pub fn new() -> Self {
let store = DataStore::load();
let volume = store.config.default_volume;
let player = AudioPlayer::new();
player.set_volume(volume as f64);
let discord = crate::discord::DiscordRpc::new(store.config.discord_client_id.clone());
let mpris = crate::mpris::MprisManager::new();
let lyrics_manager = crate::lyrics::LyricsManager::new();
let (stream_resolve_tx, stream_resolve_rx) = std::sync::mpsc::channel();
Self {
view_mode: ViewMode::Search,
query_chars: Vec::new(),
input_cursor: 0,
is_searching: false,
is_editing_search: true,
search_results: Vec::new(),
search_cursor: 0,
queue: Vec::new(),
queue_cursor: 0,
current_track_idx: None,
playlists_cursor: 0,
selected_playlist_idx: None,
playlist_detail_cursor: 0,
history_cursor: 0,
favorites_cursor: 0,
settings_cursor: 0,
current_user_profile: None,
user_profile_cursor: 0,
modal_input: None,
modal_target_track: None,
modal_playlist_cursor: 0,
player,
search_engine: SearchEngine::new(store.config.client_id.clone()),
discord,
mpris,
lyrics_manager,
current_lyrics: None,
store,
stream_resolve_tx,
stream_resolve_rx,
is_paused: false,
current_pos: 0.0,
total_duration: 0.0,
volume,
shuffle: false,
repeat: RepeatMode::Off,
prefetched_track_id: None,
current_waveform: Vec::new(),
loaded_waveform_track_id: None,
status_message: Some(("Ready. Type to search SoundCloud".to_string(), Instant::now())),
last_esc_press: None,
last_rpc_update: None,
}
}
pub fn set_status(&mut self, msg: &str) {
self.status_message = Some((msg.to_string(), Instant::now()));
}
pub fn theme_accent(&self) -> Color {
match self.store.config.theme {
ThemeColor::Auto => Color::Cyan,
ThemeColor::Catppuccin => Color::Rgb(203, 166, 247),
ThemeColor::TokyoNight => Color::Rgb(125, 207, 255),
ThemeColor::Nord => Color::Rgb(136, 192, 208),
ThemeColor::Gruvbox => Color::Rgb(254, 128, 25),
ThemeColor::Dracula => Color::Rgb(189, 147, 249),
ThemeColor::Amber => Color::Rgb(255, 176, 0),
ThemeColor::White => Color::White,
}
}
pub fn current_track(&self) -> Option<&TrackItem> {
if let Some(idx) = self.current_track_idx {
self.queue.get(idx)
} else {
None
}
}
pub fn play_track(&mut self, track: TrackItem) {
self.store.add_history(track.clone());
if let Some(pos) = self.queue.iter().position(|t| t.url == track.url) {
self.current_track_idx = Some(pos);
} else {
self.queue.push(track.clone());
self.current_track_idx = Some(self.queue.len() - 1);
}
self.is_paused = false;
self.current_pos = 0.0;
self.total_duration = track.duration;
self.prefetched_track_id = None;
self.mpris.update(
&track.title,
&track.artist,
track.artwork_url.as_deref(),
track.duration,
false,
);
self.current_lyrics = None;
self.lyrics_manager.request_lyrics(&track.id, &track.title, &track.artist);
self.fetch_waveform_for_track(&track);
if let Some(cached_file) = crate::storage::DataStore::get_cached_track_path(&track.id) {
let path_str = cached_file.to_string_lossy().to_string();
self.set_status(&format!("Playing (cached): {}", track.title));
self.player.play(&path_str);
return;
}
self.set_status(&format!("Loading: {}", track.title));
let tx = self.stream_resolve_tx.clone();
let cid_holder = std::sync::Arc::clone(&self.search_engine.cached_client_id);
let track_clone = track.clone();
std::thread::spawn(move || {
if let Some(resolved) = crate::search::SearchEngine::resolve_stream_url(&track_clone, &cid_holder) {
let _ = tx.send(StreamResolveEvent::Resolved {
track_id: track_clone.id,
url: resolved,
source: "direct",
});
} else if !track_clone.url.is_empty() && track_clone.url.starts_with("http") {
let _ = tx.send(StreamResolveEvent::Resolved {
track_id: track_clone.id,
url: track_clone.url,
source: "ytdl",
});
} else {
let _ = tx.send(StreamResolveEvent::Failed {
track_id: track_clone.id,
title: track_clone.title,
});
}
});
}
fn fetch_waveform_for_track(&mut self, track: &TrackItem) {
if self.loaded_waveform_track_id.as_deref() == Some(&track.id) {
return;
}
self.loaded_waveform_track_id = Some(track.id.clone());
self.current_waveform.clear();
if let Some(wf_url) = &track.waveform_url {
let url_clone = wf_url.clone();
let track_id_clone = track.id.clone();
std::thread::spawn(move || {
let out = std::process::Command::new("curl")
.arg("-s")
.arg("-m")
.arg("4")
.arg("-H")
.arg("User-Agent: Mozilla/5.0")
.arg(&url_clone)
.output();
if let Ok(res) = out {
if res.status.success() {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&res.stdout) {
if let Some(samples) = v.get("samples").and_then(|s| s.as_array()) {
let bytes: Vec<u8> = samples.iter().filter_map(|n| n.as_u64().map(|u| (u.min(100)) as u8)).collect();
let path = crate::storage::DataStore::cache_dir().join(format!("{}.wave", track_id_clone));
let _ = std::fs::write(path, bytes);
}
}
}
}
});
}
}
pub fn play_selected_in_current_view(&mut self) {
match self.view_mode {
ViewMode::Search => {
if !self.search_results.is_empty() && self.search_cursor < self.search_results.len() {
self.queue = self.search_results.clone();
self.current_track_idx = Some(self.search_cursor);
let track = self.queue[self.search_cursor].clone();
self.play_track(track);
}
}
ViewMode::Queue => {
if !self.queue.is_empty() && self.queue_cursor < self.queue.len() {
self.current_track_idx = Some(self.queue_cursor);
let track = self.queue[self.queue_cursor].clone();
self.play_track(track);
}
}
ViewMode::Playlists => {
if !self.store.playlists.is_empty() && self.playlists_cursor < self.store.playlists.len() {
self.selected_playlist_idx = Some(self.playlists_cursor);
self.playlist_detail_cursor = 0;
self.view_mode = ViewMode::PlaylistDetail;
}
}
ViewMode::PlaylistDetail => {
if let Some(idx) = self.selected_playlist_idx {
if let Some(pl) = self.store.playlists.get(idx) {
if !pl.tracks.is_empty() && self.playlist_detail_cursor < pl.tracks.len() {
self.queue = pl.tracks.clone();
self.current_track_idx = Some(self.playlist_detail_cursor);
let track = self.queue[self.playlist_detail_cursor].clone();
self.play_track(track);
}
}
}
}
ViewMode::UserProfile => {
if let Some(p) = &self.current_user_profile {
if !p.tracks.is_empty() && self.user_profile_cursor < p.tracks.len() {
self.queue = p.tracks.clone();
self.current_track_idx = Some(self.user_profile_cursor);
let track = self.queue[self.user_profile_cursor].clone();
self.play_track(track);
}
}
}
ViewMode::Favorites => {
if !self.store.liked.is_empty() && self.favorites_cursor < self.store.liked.len() {
self.queue = self.store.liked.clone();
self.current_track_idx = Some(self.favorites_cursor);
let track = self.queue[self.favorites_cursor].clone();
self.play_track(track);
}
}
ViewMode::History => {
if !self.store.history.is_empty() && self.history_cursor < self.store.history.len() {
self.queue = self.store.history.iter().map(|e| e.track.clone()).collect();
self.current_track_idx = Some(self.history_cursor);
let track = self.queue[self.history_cursor].clone();
self.play_track(track);
}
}
ViewMode::Settings => {
self.cycle_setting();
}
ViewMode::Lyrics => {
self.toggle_pause();
}
}
}
pub fn play_next(&mut self) {
if self.queue.is_empty() {
return;
}
if self.repeat == RepeatMode::One {
if let Some(idx) = self.current_track_idx {
if let Some(track) = self.queue.get(idx).cloned() {
self.play_track(track);
return;
}
}
}
if self.shuffle && self.queue.len() > 1 {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut next_idx = rng.gen_range(0..self.queue.len());
if Some(next_idx) == self.current_track_idx {
next_idx = (next_idx + 1) % self.queue.len();
}
let track = self.queue[next_idx].clone();
self.current_track_idx = Some(next_idx);
self.play_track(track);
return;
}
let next_idx = match self.current_track_idx {
Some(i) if i + 1 < self.queue.len() => i + 1,
_ => {
if self.repeat == RepeatMode::All || self.repeat == RepeatMode::Off {
0
} else {
return;
}
}
};
let track = self.queue[next_idx].clone();
self.current_track_idx = Some(next_idx);
self.play_track(track);
}
pub fn play_prev(&mut self) {
if self.queue.is_empty() {
return;
}
let prev_idx = match self.current_track_idx {
Some(i) if i > 0 => i - 1,
_ => self.queue.len().saturating_sub(1),
};
let track = self.queue[prev_idx].clone();
self.current_track_idx = Some(prev_idx);
self.play_track(track);
}
pub fn toggle_pause(&mut self) {
if self.current_track().is_none() {
return;
}
self.is_paused = !self.is_paused;
self.player.set_pause(self.is_paused);
self.set_status(if self.is_paused { "Paused" } else { "Playing" });
if let Some(track) = self.current_track().cloned() {
self.mpris.update(
&track.title,
&track.artist,
track.artwork_url.as_deref(),
track.duration,
self.is_paused,
);
}
}
pub fn adjust_volume(&mut self, delta: i32) {
let new_vol = (self.volume as i32 + delta).clamp(0, 150) as u32;
self.volume = new_vol;
self.player.set_volume(new_vol as f64);
self.set_status(&format!("Volume: {}%", new_vol));
}
pub fn seek(&mut self, seconds: f64) {
self.player.seek(seconds);
self.current_pos = (self.current_pos + seconds).max(0.0);
}
pub fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Right => {
self.seek(10.0);
return;
}
KeyCode::Left => {
self.seek(-10.0);
return;
}
KeyCode::Up => {
self.adjust_volume(5);
return;
}
KeyCode::Down => {
self.adjust_volume(-5);
return;
}
_ => {}
}
}
if let Some(chars) = &mut self.modal_input {
match key.code {
KeyCode::Esc => {
self.modal_input = None;
self.modal_target_track = None;
}
KeyCode::Char(c) => {
chars.push(c);
}
KeyCode::Backspace => {
chars.pop();
}
KeyCode::Enter => {
let title: String = chars.iter().collect();
if !title.trim().is_empty() {
self.store.create_playlist(title.trim());
self.set_status(&format!("Created playlist: {}", title.trim()));
}
self.modal_input = None;
}
_ => {}
}
return;
}
if self.modal_target_track.is_some() {
match key.code {
KeyCode::Esc => {
self.modal_target_track = None;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.modal_playlist_cursor > 0 {
self.modal_playlist_cursor -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.store.playlists.is_empty() && self.modal_playlist_cursor + 1 < self.store.playlists.len() {
self.modal_playlist_cursor += 1;
}
}
KeyCode::Enter => {
if let Some(track) = self.modal_target_track.take() {
if !self.store.playlists.is_empty() && self.modal_playlist_cursor < self.store.playlists.len() {
let pl_title = self.store.playlists[self.modal_playlist_cursor].title.clone();
if self.store.add_track_to_playlist(self.modal_playlist_cursor, track) {
self.set_status(&format!("Added to '{}'", pl_title));
} else {
self.set_status(&format!("Track already in '{}'", pl_title));
}
}
}
}
_ => {}
}
return;
}
match key.code {
KeyCode::F(1) => {
self.view_mode = ViewMode::Search;
self.is_editing_search = true;
return;
}
KeyCode::F(2) => {
self.view_mode = ViewMode::Queue;
self.is_editing_search = false;
return;
}
KeyCode::F(3) => {
self.view_mode = ViewMode::Playlists;
self.is_editing_search = false;
return;
}
KeyCode::F(4) => {
self.view_mode = ViewMode::Favorites;
self.is_editing_search = false;
return;
}
KeyCode::F(5) => {
self.view_mode = ViewMode::History;
self.is_editing_search = false;
return;
}
KeyCode::F(6) => {
self.view_mode = ViewMode::Settings;
self.is_editing_search = false;
return;
}
KeyCode::Tab => {
self.view_mode = match self.view_mode {
ViewMode::Search => ViewMode::Queue,
ViewMode::Queue => ViewMode::Playlists,
ViewMode::Playlists => ViewMode::Favorites,
ViewMode::PlaylistDetail => ViewMode::Playlists,
ViewMode::Favorites => ViewMode::History,
ViewMode::History => ViewMode::Lyrics,
ViewMode::Lyrics => ViewMode::Settings,
ViewMode::UserProfile => ViewMode::Search,
ViewMode::Settings => ViewMode::Search,
};
if self.view_mode != ViewMode::Search {
self.is_editing_search = false;
}
return;
}
_ => {}
}
if self.view_mode == ViewMode::Search && self.is_editing_search {
match key.code {
KeyCode::Esc => {
self.is_editing_search = false;
self.set_status("Navigation mode (Space to Play/Pause, Enter to Play)");
return;
}
KeyCode::Char(' ') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
self.toggle_pause();
return;
}
KeyCode::Char('p') if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => {
self.toggle_pause();
return;
}
KeyCode::Char(c) => {
self.query_chars.insert(self.input_cursor, c);
self.input_cursor += 1;
}
KeyCode::Backspace => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
self.query_chars.remove(self.input_cursor);
}
}
KeyCode::Left => {
if self.input_cursor > 0 {
self.input_cursor -= 1;
}
}
KeyCode::Right => {
if self.input_cursor < self.query_chars.len() {
self.input_cursor += 1;
}
}
KeyCode::Enter => {
let query: String = self.query_chars.iter().collect();
if !query.trim().is_empty() {
self.is_searching = true;
self.set_status(&format!("Searching for '{}'...", query.trim()));
self.search_engine.search(&query);
self.is_editing_search = false;
}
}
KeyCode::Down => {
self.is_editing_search = false;
}
_ => {}
}
return;
}
match key.code {
KeyCode::Char(' ') => {
if self.current_track().is_some() {
self.toggle_pause();
} else {
self.play_selected_in_current_view();
}
}
KeyCode::Char('n') => {
self.play_next();
}
KeyCode::Char('p') => {
self.play_prev();
}
KeyCode::Char('y') => {
self.view_mode = if self.view_mode == ViewMode::Lyrics {
ViewMode::Queue
} else {
ViewMode::Lyrics
};
self.is_editing_search = false;
}
KeyCode::Char('S') => {
if let Some(token) = self.store.config.oauth_token.clone() {
self.set_status("Syncing your SoundCloud library...");
self.search_engine.sync_library(&token);
} else {
self.set_status("Set 'oauth_token' in ~/.config/soundcloud-tui/config.json first");
}
}
KeyCode::Char('s') => {
self.shuffle = !self.shuffle;
self.set_status(&format!("Shuffle: {}", if self.shuffle { "ON" } else { "OFF" }));
}
KeyCode::Char('r') => {
self.repeat = match self.repeat {
RepeatMode::Off => RepeatMode::All,
RepeatMode::All => RepeatMode::One,
RepeatMode::One => RepeatMode::Off,
};
self.set_status(&format!("Repeat: {}", self.repeat.name().to_uppercase()));
}
KeyCode::Char('u') => {
let track = match self.view_mode {
ViewMode::Search => self.search_results.get(self.search_cursor).cloned(),
ViewMode::Queue => self.queue.get(self.queue_cursor).cloned(),
ViewMode::Favorites => self.store.liked.get(self.favorites_cursor).cloned(),
ViewMode::History => self.store.history.get(self.history_cursor).map(|h| h.track.clone()),
ViewMode::PlaylistDetail => {
self.selected_playlist_idx.and_then(|i| self.store.playlists.get(i)).and_then(|p| p.tracks.get(self.playlist_detail_cursor).cloned())
}
_ => self.current_track().cloned(),
};
if let Some(t) = track {
let query = t.user_permalink.as_deref().or(t.user_id.as_deref()).unwrap_or(&t.artist);
self.set_status(&format!("Loading profile of '{}'...", t.artist));
self.search_engine.fetch_user(query);
}
}
KeyCode::Char('c') => {
if self.view_mode == ViewMode::Playlists {
self.modal_input = Some(Vec::new());
}
}
KeyCode::Char('m') => {
let track = match self.view_mode {
ViewMode::Search => self.search_results.get(self.search_cursor).cloned(),
ViewMode::Queue => self.queue.get(self.queue_cursor).cloned(),
ViewMode::Favorites => self.store.liked.get(self.favorites_cursor).cloned(),
ViewMode::History => self.store.history.get(self.history_cursor).map(|h| h.track.clone()),
ViewMode::UserProfile => {
self.current_user_profile.as_ref().and_then(|p| p.tracks.get(self.user_profile_cursor).cloned())
}
_ => self.current_track().cloned(),
};
if let Some(t) = track {
if self.store.playlists.is_empty() {
self.set_status("No playlists yet. Press 'c' in Playlists view to create one");
} else {
self.modal_target_track = Some(t);
self.modal_playlist_cursor = 0;
}
}
}
KeyCode::Char('l') => {
if let Some(track) = self.current_track().cloned() {
let liked = self.store.toggle_like(&track);
if liked {
self.set_status(&format!("Added to Favorites: {}", track.title));
} else {
self.set_status(&format!("Removed from Favorites: {}", track.title));
}
}
}
KeyCode::Char('i') | KeyCode::Char('/') => {
if self.view_mode == ViewMode::Search {
self.is_editing_search = true;
}
}
KeyCode::Up | KeyCode::Char('k') => match self.view_mode {
ViewMode::Search => {
if self.search_cursor > 0 {
self.search_cursor -= 1;
}
}
ViewMode::Queue => {
if self.queue_cursor > 0 {
self.queue_cursor -= 1;
}
}
ViewMode::Playlists => {
if self.playlists_cursor > 0 {
self.playlists_cursor -= 1;
}
}
ViewMode::PlaylistDetail => {
if self.playlist_detail_cursor > 0 {
self.playlist_detail_cursor -= 1;
}
}
ViewMode::UserProfile => {
if self.user_profile_cursor > 0 {
self.user_profile_cursor -= 1;
}
}
ViewMode::Favorites => {
if self.favorites_cursor > 0 {
self.favorites_cursor -= 1;
}
}
ViewMode::History => {
if self.history_cursor > 0 {
self.history_cursor -= 1;
}
}
ViewMode::Settings => {
if self.settings_cursor > 0 {
self.settings_cursor -= 1;
}
}
ViewMode::Lyrics => {
self.seek(-5.0);
}
},
KeyCode::Down | KeyCode::Char('j') => match self.view_mode {
ViewMode::Search => {
if !self.search_results.is_empty()
&& self.search_cursor + 1 < self.search_results.len()
{
self.search_cursor += 1;
}
}
ViewMode::Queue => {
if !self.queue.is_empty() && self.queue_cursor + 1 < self.queue.len() {
self.queue_cursor += 1;
}
}
ViewMode::Playlists => {
if !self.store.playlists.is_empty()
&& self.playlists_cursor + 1 < self.store.playlists.len()
{
self.playlists_cursor += 1;
}
}
ViewMode::PlaylistDetail => {
if let Some(idx) = self.selected_playlist_idx {
if let Some(pl) = self.store.playlists.get(idx) {
if !pl.tracks.is_empty() && self.playlist_detail_cursor + 1 < pl.tracks.len() {
self.playlist_detail_cursor += 1;
}
}
}
}
ViewMode::UserProfile => {
if let Some(p) = &self.current_user_profile {
if !p.tracks.is_empty() && self.user_profile_cursor + 1 < p.tracks.len() {
self.user_profile_cursor += 1;
}
}
}
ViewMode::Favorites => {
if !self.store.liked.is_empty()
&& self.favorites_cursor + 1 < self.store.liked.len()
{
self.favorites_cursor += 1;
}
}
ViewMode::History => {
if !self.store.history.is_empty()
&& self.history_cursor + 1 < self.store.history.len()
{
self.history_cursor += 1;
}
}
ViewMode::Settings => {
if self.settings_cursor < 3 {
self.settings_cursor += 1;
}
}
ViewMode::Lyrics => {
self.seek(5.0);
}
},
KeyCode::Enter => {
self.play_selected_in_current_view();
},
KeyCode::Char('a') => {
let track = match self.view_mode {
ViewMode::Search => self.search_results.get(self.search_cursor).cloned(),
ViewMode::Favorites => self.store.liked.get(self.favorites_cursor).cloned(),
ViewMode::History => {
self.store.history.get(self.history_cursor).map(|h| h.track.clone())
}
ViewMode::UserProfile => {
self.current_user_profile.as_ref().and_then(|p| p.tracks.get(self.user_profile_cursor).cloned())
}
ViewMode::PlaylistDetail => {
if let Some(idx) = self.selected_playlist_idx {
self.store.playlists.get(idx).and_then(|p| p.tracks.get(self.playlist_detail_cursor).cloned())
} else {
None
}
}
_ => None,
};
if let Some(t) = track {
self.set_status(&format!("Added to Queue: {}", t.title));
self.queue.push(t);
}
}
KeyCode::Char('d') => {
if self.view_mode == ViewMode::Queue && !self.queue.is_empty() {
let removed = self.queue.remove(self.queue_cursor);
self.set_status(&format!("Removed from Queue: {}", removed.title));
if self.queue_cursor >= self.queue.len() && self.queue_cursor > 0 {
self.queue_cursor -= 1;
}
} else if self.view_mode == ViewMode::Favorites && !self.store.liked.is_empty() {
let removed = self.store.liked.remove(self.favorites_cursor);
self.store.save();
self.set_status(&format!("Removed from Favorites: {}", removed.title));
if self.favorites_cursor >= self.store.liked.len() && self.favorites_cursor > 0 {
self.favorites_cursor -= 1;
}
} else if self.view_mode == ViewMode::Playlists && !self.store.playlists.is_empty() {
if let Some(removed) = self.store.remove_playlist(self.playlists_cursor) {
self.set_status(&format!("Removed Playlist: {}", removed.title));
if self.playlists_cursor >= self.store.playlists.len() && self.playlists_cursor > 0 {
self.playlists_cursor -= 1;
}
}
} else if self.view_mode == ViewMode::PlaylistDetail {
if let Some(pl_idx) = self.selected_playlist_idx {
if let Some(removed) = self.store.remove_track_from_playlist(pl_idx, self.playlist_detail_cursor) {
self.set_status(&format!("Removed track: {}", removed.title));
let track_count = self.store.playlists.get(pl_idx).map(|p| p.tracks.len()).unwrap_or(0);
if self.playlist_detail_cursor >= track_count && self.playlist_detail_cursor > 0 {
self.playlist_detail_cursor -= 1;
}
}
}
}
}
KeyCode::Left | KeyCode::Char('b') => {
self.seek(-5.0);
}
KeyCode::Right | KeyCode::Char('f') => {
self.seek(5.0);
}
KeyCode::Char('+') | KeyCode::Char('=') => {
self.adjust_volume(5);
}
KeyCode::Char('-') => {
self.adjust_volume(-5);
}
_ => {}
}
}
fn cycle_setting(&mut self) {
match self.settings_cursor {
0 => {
self.store.config.theme = match self.store.config.theme {
ThemeColor::Auto => ThemeColor::Catppuccin,
ThemeColor::Catppuccin => ThemeColor::TokyoNight,
ThemeColor::TokyoNight => ThemeColor::Nord,
ThemeColor::Nord => ThemeColor::Gruvbox,
ThemeColor::Gruvbox => ThemeColor::Dracula,
ThemeColor::Dracula => ThemeColor::Amber,
ThemeColor::Amber => ThemeColor::White,
ThemeColor::White => ThemeColor::Auto,
};
}
1 => {
self.store.config.visual = match self.store.config.visual {
VisualStyle::Tape => VisualStyle::Minimal,
VisualStyle::Minimal => VisualStyle::Blocky,
VisualStyle::Blocky => VisualStyle::Tape,
};
}
2 => {
self.volume = if self.volume >= 100 { 50 } else { self.volume + 25 };
self.store.config.default_volume = self.volume;
self.player.set_volume(self.volume as f64);
}
3 => {
self.store.config.discord_rpc = !self.store.config.discord_rpc;
if !self.store.config.discord_rpc {
self.discord.clear();
}
}
_ => {}
}
self.store.save();
}
pub fn poll_async_events(&mut self) {
while let Ok(res) = self.stream_resolve_rx.try_recv() {
match res {
StreamResolveEvent::Resolved { track_id, url, source } => {
if self.current_track().map(|t| t.id == track_id).unwrap_or(false) {
self.player.play(&url);
if self.is_paused {
self.player.set_pause(true);
}
let title = self.current_track().map(|t| t.title.as_str()).unwrap_or("");
let label = if source == "direct" { "Streaming" } else { "Streaming (ytdl)" };
self.set_status(&format!("{}: {}", label, title));
}
}
StreamResolveEvent::Failed { track_id, title } => {
if self.current_track().map(|t| t.id == track_id).unwrap_or(false) {
self.set_status(&format!("Track unavailable: {}", title));
}
}
}
}
while let Some(ev) = self.player.poll_event() {
match ev {
crate::audio::PlayerEvent::TimePos(pos) => self.current_pos = pos,
crate::audio::PlayerEvent::Duration(dur) => self.total_duration = dur,
crate::audio::PlayerEvent::Paused(p) => self.is_paused = p,
crate::audio::PlayerEvent::Volume(v) => self.volume = v as u32,
crate::audio::PlayerEvent::PlaybackEnded => self.play_next(),
crate::audio::PlayerEvent::Idle(idle) => {
if idle && self.current_track().is_some() && self.total_duration > 10.0 && self.current_pos >= (self.total_duration - 4.0).max(1.0) {
self.play_next();
}
}
crate::audio::PlayerEvent::PlaybackError(err) => {
self.set_status(&format!("Playback error: {}", err));
}
}
}
if self.total_duration > 15.0 && (self.current_pos / self.total_duration) >= 0.70 {
if let Some(cur_idx) = self.current_track_idx {
if !self.queue.is_empty() {
let next_idx = (cur_idx + 1) % self.queue.len();
if let Some(next_track) = self.queue.get(next_idx) {
if self.prefetched_track_id.as_deref() != Some(&next_track.id) {
self.prefetched_track_id = Some(next_track.id.clone());
SearchEngine::prefetch_track(next_track, &self.search_engine.cached_client_id);
}
}
}
}
}
if let Some(track) = self.current_track() {
if self.current_waveform.is_empty() {
let wf_file = crate::storage::DataStore::cache_dir().join(format!("{}.wave", track.id));
if wf_file.exists() {
if let Ok(bytes) = std::fs::read(wf_file) {
self.current_waveform = bytes;
}
}
}
}
if self.store.config.discord_rpc {
let should_update = match self.last_rpc_update {
Some(last) => last.elapsed() >= std::time::Duration::from_secs(1),
None => true,
};
if should_update {
self.last_rpc_update = Some(std::time::Instant::now());
if let Some(track) = self.current_track() {
self.discord.update(
&track.title,
&track.artist,
track.artwork_url.clone(),
self.current_pos,
self.total_duration,
self.is_paused,
);
} else {
self.discord.clear();
}
}
}
while let Some(mpris_ev) = self.mpris.poll_event() {
match mpris_ev {
crate::mpris::MprisEvent::Play => {
if self.is_paused {
self.toggle_pause();
}
}
crate::mpris::MprisEvent::Pause => {
if !self.is_paused {
self.toggle_pause();
}
}
crate::mpris::MprisEvent::Toggle => {
self.toggle_pause();
}
crate::mpris::MprisEvent::Next => {
self.play_next();
}
crate::mpris::MprisEvent::Previous => {
self.play_prev();
}
}
}
while let Some(lyrics) = self.lyrics_manager.poll_lyrics() {
if let Some(track) = self.current_track() {
if track.id == lyrics.track_id {
self.current_lyrics = Some(lyrics);
}
}
}
if let Some(res) = self.search_engine.poll_result() {
self.is_searching = false;
match res {
SearchResult::Success(tracks) => {
self.set_status(&format!("Found {} tracks", tracks.len()));
self.search_results = tracks;
self.search_cursor = 0;
}
SearchResult::PlaylistLoaded(playlist) => {
self.set_status(&format!("Playlist loaded: {} ({} tracks)", playlist.title, playlist.track_count));
self.store.save_playlist(playlist);
self.selected_playlist_idx = Some(0);
self.playlist_detail_cursor = 0;
self.view_mode = ViewMode::PlaylistDetail;
}
SearchResult::UserProfileLoaded(profile) => {
self.set_status(&format!("Loaded profile: @{} ({} tracks)", profile.username, profile.track_count));
self.current_user_profile = Some(profile);
self.user_profile_cursor = 0;
self.view_mode = ViewMode::UserProfile;
}
SearchResult::LibraryLoaded { likes, playlists } => {
let likes_len = likes.len();
let pl_len = playlists.len();
for t in likes {
if !self.store.is_liked(&t.url) {
self.store.liked.push(t);
}
}
for pl in playlists {
self.store.save_playlist(pl);
}
self.store.save();
self.set_status(&format!("Synced library: {} likes, {} playlists", likes_len, pl_len));
}
SearchResult::Error(err) => {
self.set_status(&format!("Error: {}", err));
}
}
}
}
}