Skip to content

Commit b86d727

Browse files
author
Ross
committed
Add favorite/unfavorite song functionality among other
- Add make_favorite() method to App for favoriting songs from queue, tracks, or search tabs - Add favorite_a_song() API method to SubsonicClient supporting both star and unstar endpoints - Add keybindings: 'f' to favorite, 'F' (shift+f) to unfavorite - Fix can_next/can_prev to use playing_index instead of queue_tab.index - Minor cleanup: reorder imports, switch from interval() to sleep()" - Added lazy loading of songs - Moved loading of songs to task thread to free up the ui - Fixed bug in remote search. (Need to look into why it repeats songs)
1 parent 6de4ce2 commit b86d727

8 files changed

Lines changed: 304 additions & 83 deletions

File tree

src/app.rs

Lines changed: 171 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,23 @@ pub mod queue;
77
pub mod search;
88
use crate::{
99
config::{Config, ConfigError},
10-
mpris_handler::{MprisPlayer},
10+
mpris_handler::MprisPlayer,
1111
player::{Player, PlayerCommand, PlayerState, SharedPlayerState},
1212
search::SearchEngine,
1313
subsonic::SubsonicClient,
1414
};
1515
use anyhow::Result;
16-
use crossterm::{
17-
terminal::disable_raw_mode,
18-
};
19-
use mpris_server::{Metadata, PlaybackStatus , Server, Time};
16+
use crossterm::terminal::disable_raw_mode;
17+
use mpris_server::{Metadata, PlaybackStatus, Server, Time};
2018
use ratatui::widgets::ListState;
21-
use ratatui_image::{ protocol::StatefulProtocol};
19+
use ratatui_image::protocol::StatefulProtocol;
2220
use std::{
2321
io::{self, Write},
2422
rc::Rc,
2523
sync::{Arc, RwLock},
24+
time::Duration,
2625
};
27-
use tokio::{
28-
sync::{Mutex, mpsc},
29-
};
26+
use tokio::sync::{Mutex, mpsc};
3027

3128
pub struct TerminalGuard; // used to make sure terminal goes back to normal
3229
impl TerminalGuard {
@@ -57,7 +54,6 @@ impl Drop for TerminalGuard {
5754
}
5855
}
5956

60-
6157
#[derive(Debug, thiserror::Error)]
6258
pub enum AppError {
6359
#[error("No Track Loaded")]
@@ -157,9 +153,23 @@ pub enum ActiveTab {
157153
Favorites,
158154
}
159155

156+
pub enum LibraryMessage {
157+
Loaded {
158+
songs: Vec<Track>,
159+
artists: Vec<Artist>,
160+
albums: Vec<Album>,
161+
playlists: Vec<Playlists>,
162+
favorites: Vec<Track>,
163+
},
164+
SongsAppended(Vec<Track>),
165+
Error(String),
166+
}
167+
160168
pub struct App {
161169
pub config: Config,
162170
pub subsonic_client: Arc<SubsonicClient>,
171+
pub needs_initial_load: bool,
172+
pub library_rx: Option<mpsc::Receiver<LibraryMessage>>,
163173
pub player: Rc<Mutex<Player>>,
164174
pub is_playing: bool,
165175
pub current_track: Option<Track>,
@@ -169,6 +179,9 @@ pub struct App {
169179
pub shared_state: SharedPlayerState,
170180
pub command_receiver: mpsc::Receiver<PlayerCommand>,
171181
pub metadata: Metadata,
182+
pub widget_notification: Option<(String, std::time::Instant)>,
183+
pub w_notification_duration: std::time::Duration,
184+
pub last_search_keystroke: Option<std::time::Instant>,
172185
// TabSelection
173186
pub queue_tab: TabSelection<Track>,
174187
pub tracks_tab: TabSelection<Track>,
@@ -234,31 +247,33 @@ impl App {
234247
position: Time::ZERO,
235248
}));
236249
let mprisserver = {
237-
238-
let mut result = None;
239-
for i in 0..10u32{
240-
let iface = MprisPlayer::new(tx.clone() , shared_state.clone());
241-
let name = if i ==0 {
242-
"sonicrust".to_string()
243-
} else {
244-
format!("sonicrust.instance{}",i)
245-
};
246-
match Server::new(&name, iface).await{
247-
Ok(s) => {
248-
result = Some(s);
249-
break;
250-
}
251-
Err(e) => {
252-
eprintln!("Mpris name '{}' taken, trying next... ({})",name,e);
250+
let mut result = None;
251+
for i in 0..10u32 {
252+
let iface = MprisPlayer::new(tx.clone(), shared_state.clone());
253+
let name = if i == 0 {
254+
"sonicrust".to_string()
255+
} else {
256+
format!("sonicrust.instance{}", i)
257+
};
258+
match Server::new(&name, iface).await {
259+
Ok(s) => {
260+
result = Some(s);
261+
break;
262+
}
263+
Err(e) => {
264+
eprintln!("Mpris name '{}' taken, trying next... ({})", name, e);
265+
}
253266
}
254267
}
255-
}
256-
result.ok_or_else(|| anyhow::anyhow!("Failed to register any MPRIS name after 10 attempts"))?
257-
};
268+
result.ok_or_else(|| {
269+
anyhow::anyhow!("Failed to register any MPRIS name after 10 attempts")
270+
})?
271+
};
258272
let search_engine = SearchEngine::new(config.search.fuzzy_threshold, 30);
259273

260-
let mut app = Self {
274+
let app = Self {
261275
config,
276+
needs_initial_load: true,
262277
subsonic_client: subsonic_client.clone(),
263278
player,
264279
metadata: Metadata::default(),
@@ -267,6 +282,9 @@ impl App {
267282
current_track: None,
268283
current_volume: 1.0,
269284
shared_state,
285+
last_search_keystroke: None,
286+
widget_notification: None,
287+
w_notification_duration: std::time::Duration::from_secs(3),
270288
tracks_tab: TabSelection::new(),
271289
queue_tab: TabSelection::new(),
272290
artist_tab: TabSelection::new(),
@@ -276,6 +294,7 @@ impl App {
276294
favorite_tab: TabSelection::new(),
277295
mpris: mprisserver,
278296
command_receiver: rx,
297+
library_rx: None,
279298
active_tab: ActiveTab::Songs,
280299
active_section: ActiveSection::Others,
281300
input_mode: InputMode::Normal,
@@ -287,10 +306,47 @@ impl App {
287306
cover_art_protocol: None,
288307
};
289308

290-
app.refresh_library().await?;
309+
// app.refresh_library().await?;
291310
Ok(app)
292311
}
293312
pub async fn update(&mut self) -> Result<()> {
313+
if self.needs_initial_load {
314+
self.needs_initial_load = false;
315+
self.start_background_load();
316+
self.set_notification("Loading Library...");
317+
// self.refresh_library().await?;
318+
// self.set_notification("Library loaded");
319+
}
320+
if let Some(rx) = &mut self.library_rx {
321+
match rx.try_recv() {
322+
Ok(LibraryMessage::Loaded {
323+
songs,
324+
artists,
325+
albums,
326+
playlists,
327+
favorites,
328+
}) => {
329+
self.tracks_tab.data = songs;
330+
self.artist_tab.data = artists;
331+
self.album_tab.data = albums;
332+
self.playlist_tab.data = playlists;
333+
self.favorite_tab.data = favorites;
334+
// self.library_rx = None;
335+
self.set_notification("Library Loaded");
336+
}
337+
Ok(LibraryMessage::SongsAppended(songs)) => {
338+
self.tracks_tab.data.extend(songs);
339+
}
340+
Ok(LibraryMessage::Error(e)) => {
341+
self.set_notification(format!("Load Error: {}", e));
342+
self.library_rx = None;
343+
}
344+
Err(mpsc::error::TryRecvError::Empty) => {} // This means it is still loading
345+
Err(_) => {
346+
self.library_rx = None;
347+
}
348+
}
349+
}
294350
while let Ok(cmd) = self.command_receiver.try_recv() {
295351
match cmd {
296352
PlayerCommand::Play => {
@@ -351,17 +407,96 @@ impl App {
351407
}
352408
}
353409
}
410+
if let Some(t) = self.last_search_keystroke
411+
&& t.elapsed() > Duration::from_millis(300)
412+
&& self.is_searching
413+
{
414+
self.last_search_keystroke = None;
415+
self.perform_search().await?;
416+
}
354417

355418
self.check_track_finished().await?;
356419
self.update_mpris_position().await?;
420+
self.tick_notification();
357421
Ok(())
358422
}
423+
pub fn set_notification(&mut self, msg: impl Into<String>) {
424+
self.widget_notification = Some((msg.into(), std::time::Instant::now()));
425+
}
426+
pub fn tick_notification(&mut self) {
427+
if let Some((_, created)) = &self.widget_notification
428+
&& created.elapsed() >= self.w_notification_duration
429+
{
430+
self.widget_notification = None;
431+
}
432+
}
433+
pub fn start_background_load(&mut self) {
434+
let (tx, rx) = mpsc::channel(4);
435+
self.library_rx = Some(rx);
436+
let client = self.subsonic_client.clone();
437+
tokio::spawn(async move {
438+
let (first_page, artists, albums, playlists, favorites) = match tokio::try_join!(
439+
client.get_album_page(0, 10),
440+
// client.get_all_songs(),
441+
client.get_all_artists(),
442+
client.get_all_albums(),
443+
client.get_playlists(),
444+
client.get_all_favorites(),
445+
) {
446+
Ok(r) => r,
447+
Err(e) => {
448+
let _ = tx.send(LibraryMessage::Error(e.to_string())).await;
449+
return;
450+
}
451+
};
452+
let first_songs = {
453+
let futures = first_page.iter().map(|a| client.get_songs_in_album(a));
454+
futures::future::join_all(futures)
455+
.await
456+
.into_iter()
457+
.flat_map(|r| r.unwrap_or_default())
458+
.collect::<Vec<_>>()
459+
};
460+
let _ = tx
461+
.send(LibraryMessage::Loaded {
462+
songs: first_songs,
463+
artists,
464+
albums: albums.clone(),
465+
playlists,
466+
favorites,
467+
})
468+
.await;
469+
let remaining = albums.iter().skip(10);
470+
let chunks = remaining.collect::<Vec<_>>();
471+
for c in chunks.chunks(100) {
472+
let futures = c.iter().map(|a| client.get_songs_in_album(a));
473+
let songs = futures::future::join_all(futures)
474+
.await
475+
.into_iter()
476+
.flat_map(|r| r.unwrap_or_default())
477+
.collect::<Vec<_>>();
478+
if tx.send(LibraryMessage::SongsAppended(songs)).await.is_err() {
479+
break;
480+
}
481+
}
482+
});
483+
}
359484
pub async fn refresh_library(&mut self) -> Result<()> {
360-
self.tracks_tab.data = self.subsonic_client.get_all_songs().await?;
361-
self.artist_tab.data = self.subsonic_client.get_all_artists().await?;
362-
self.album_tab.data = self.subsonic_client.get_all_albums().await?;
363-
self.playlist_tab.data = self.subsonic_client.get_playlists().await?;
364-
self.favorite_tab.data = self.subsonic_client.get_all_favorites().await?;
485+
let client = self.subsonic_client.clone();
486+
self.set_notification("Loading Library...");
487+
let (songs, artist, albums, playlists, favorites) = tokio::try_join!(
488+
client.get_all_songs(),
489+
client.get_all_artists(),
490+
client.get_all_albums(),
491+
client.get_playlists(),
492+
client.get_all_favorites(),
493+
)?;
494+
self.tracks_tab.data = songs;
495+
self.artist_tab.data = artist;
496+
self.album_tab.data = albums;
497+
self.playlist_tab.data = playlists;
498+
self.favorite_tab.data = favorites;
499+
365500
Ok(())
366501
}
367502
}

src/app/playback.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use anyhow::Result;
12
use futures::future;
23
use mpris_server::{Metadata, Property};
3-
use anyhow::Result;
44

5-
use crate::{app::{ActiveSection, ActiveTab, AppError, Track, VolumeDirection}, mpris_handler::track_to_metadata};
5+
use crate::{
6+
app::{ActiveSection, ActiveTab, AppError, Track, VolumeDirection},
7+
mpris_handler::track_to_metadata,
8+
};
69

710
use super::App;
811
impl App {
@@ -33,7 +36,14 @@ impl App {
3336
}
3437

3538
async fn start_playback(&mut self, track: Track, queue_index: usize) -> Result<()> {
36-
let stream_url = self.subsonic_client.get_stream_url(&track.id)?;
39+
let stream_url = match &self.config.search.mode {
40+
crate::config::SearchMode::Remote => {
41+
self.subsonic_client
42+
.get_stream_url_with_retry(&track.id, 5)
43+
.await?
44+
}
45+
crate::config::SearchMode::Local => self.subsonic_client.get_stream_url(&track.id)?,
46+
};
3747
{
3848
let mut player = self.player.lock().await;
3949
player.load_url(&stream_url).await?;

src/app/queue.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,22 +74,41 @@ impl App {
7474
if !self.queue_tab.data.is_empty() {
7575
let song = self.queue_tab.get().unwrap();
7676
self.subsonic_client.favorite_a_song(song, remove).await?;
77+
let msg = if remove {
78+
format!("Removed '{}' from favorites", song.title)
79+
} else {
80+
format!("Added '{}' to favorites", song.title)
81+
};
82+
self.set_notification(msg);
7783
}
7884
}
7985
(ActiveSection::Others, ActiveTab::Songs) => {
8086
if !self.tracks_tab.data.is_empty() {
8187
let song = self.tracks_tab.get().unwrap();
8288
self.subsonic_client.favorite_a_song(song, remove).await?;
89+
let msg = if remove {
90+
format!("Removed '{}' from favorites", song.title)
91+
} else {
92+
format!("Added '{}' to favorites", song.title)
93+
};
94+
self.set_notification(msg);
8395
}
8496
}
8597
(ActiveSection::Others, ActiveTab::Search) => {
8698
if !self.search_tab.data.is_empty() {
8799
let song = self.search_tab.get().unwrap();
88100
self.subsonic_client.favorite_a_song(song, remove).await?;
101+
let msg = if remove {
102+
format!("Removed '{}' from favorites", song.title)
103+
} else {
104+
format!("Added '{}' to favorites", song.title)
105+
};
106+
self.set_notification(msg);
89107
}
90108
}
91109
_ => (),
92-
}
110+
};
111+
self.favorite_tab.data = self.subsonic_client.get_all_favorites().await?;
93112
Ok(())
94113
}
95114
}

0 commit comments

Comments
 (0)