Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions backend/commands/subsonic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,12 +541,27 @@ pub async fn scrobble(state: Arc<AppState>, id: String, submission: bool, time:
}
}

/// Sets a 1–5 star rating on a track (0 clears it). Logged-only on error.
pub async fn set_rating(state: Arc<AppState>, id: String, rating: u32) {
/// Sets a 1–5 star rating on a track (0 clears it).
pub async fn set_rating(state: Arc<AppState>, id: String, rating: u32) -> Result<(), crate::errors::UserError> {
let params = [("id", id), ("rating", rating.to_string())];
if let Err(e) = subsonic_request(&state, "setRating", &params, true).await {
eprintln!("Set rating failed: {}", e.message());
}
subsonic_request(&state, "setRating", &params, true).await?;
Ok(())
}

/// Fetches a single song by id, picks up the server-recomputed
/// `averageRating` right after `set_rating
pub async fn get_song(state: Arc<AppState>, id: String) -> Result<Song, crate::errors::UserError> {
let body = subsonic_request(&state, "getSong", &[("id", id)], false).await?;
let song = body.get("song").cloned().ok_or(crate::errors::UserError::Unknown)?;
map_songs(vec![song]).into_iter().next().ok_or(crate::errors::UserError::Unknown)
}

/// Sets a rating then re-fetches the song, so the caller can pick up the
/// server-recomputed `averageRating` in one round trip. If the write itself
/// fails, returns that error without fetching (nothing changed to reconcile).
pub async fn set_rating_and_refetch(state: Arc<AppState>, id: String, rating: u32) -> Result<Song, crate::errors::UserError> {
set_rating(state.clone(), id.clone(), rating).await?;
get_song(state, id).await
}

/// Which entity a star/unstar call targets — Subsonic's `star`/`unstar` accept
Expand Down
120 changes: 112 additions & 8 deletions desktop/src/app/cover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,29 @@ impl App {
}
}

/// Whether the given id is currently starred, checked against whichever
/// in-memory collection holds it (album detail, queue).
/// Finds a song by id among the collections that can show the heart toggle
fn find_song_for_star(&self, id: &str) -> Option<&firmium_backend::commands::mappers::Song> {
self.queue.iter()
.chain(self.album_detail.iter().flat_map(|at| at.tracks.iter()))
.chain(self.playlist_detail.iter().flat_map(|pt| pt.tracks.iter()))
.chain(self.genre_songs.iter())
.chain(self.favorites.iter().flat_map(|f| f.songs.iter()))
.find(|s| s.id == id)
}

/// Whether the given id is currently starred.
pub(crate) fn is_starred(&self, id: &str, kind: firmium_backend::commands::subsonic::StarKind) -> bool {
use firmium_backend::commands::subsonic::StarKind;
match kind {
StarKind::Song => {
self.queue.iter().find(|s| s.id == id).map(|s| s.starred)
.or_else(|| self.album_detail.as_ref()?.tracks.iter().find(|s| s.id == id).map(|s| s.starred))
.unwrap_or(false)
StarKind::Song => self.find_song_for_star(id).is_some_and(|s| s.starred),
StarKind::Album => {
self.album_detail.as_ref().is_some_and(|at| at.starred)
|| self.favorites.as_ref().is_some_and(|f| f.albums.iter().any(|a| a.id == id && a.starred))
}
StarKind::Album => self.album_detail.as_ref().is_some_and(|at| at.starred),
StarKind::Artist => false, // artist starring has no local UI state to flip (list-only in Favorites screen).
}
}

/// Flips the starred flag on every in-memory copy of this id (queue, album detail).
pub(crate) fn set_starred_locally(&mut self, id: &str, kind: firmium_backend::commands::subsonic::StarKind, starred: bool) {
use firmium_backend::commands::subsonic::StarKind;
match kind {
Expand All @@ -94,16 +101,110 @@ impl App {
if s.id == id { s.starred = starred; }
}
}
if let Some(pt) = self.playlist_detail.as_mut() {
for s in pt.tracks.iter_mut() {
if s.id == id { s.starred = starred; }
}
}
for s in self.genre_songs.iter_mut() {
if s.id == id { s.starred = starred; }
}
if starred {
if let Some(song) = self.find_song_for_star(id).cloned() {
if let Some(f) = self.favorites.as_mut() {
if !f.songs.iter().any(|s| s.id == id) {
f.songs.push(song);
}
}
}
} else if let Some(f) = self.favorites.as_mut() {
f.songs.retain(|s| s.id != id);
}
}
StarKind::Album => {
if let Some(at) = self.album_detail.as_mut() {
at.starred = starred;
}
if starred {
if let Some(at) = &self.album_detail {
let album = firmium_backend::commands::mappers::Album {
id: id.to_string(),
name: at.album_name.clone(),
album_artist: at.album_artist.clone(),
artist_id: None, // Isnt rendered
cover_art_id: at.cover_art_id.clone(),
song_count: Some(at.tracks.len() as u32),
release_type: String::new(),
genres: None, // Isnt rendered
year: None, // Isnt rendered
is_compilation: false, // Isnt rendered
starred: true,
};
if let Some(f) = self.favorites.as_mut() {
if !f.albums.iter().any(|a| a.id == id) {
f.albums.push(album);
}
}
}
} else if let Some(f) = self.favorites.as_mut() {
f.albums.retain(|a| a.id != id);
}
}
StarKind::Artist => {}
}
}

/// Finds a song by id among the collections that can show the rating
/// stars/average badge. Deliberately excludes `queue`: the player bar has
/// no rating UI, and queue entries aren't kept in sync with rating changes
fn find_song_for_rating(&self, id: &str) -> Option<&firmium_backend::commands::mappers::Song> {
self.album_detail.iter().flat_map(|at| at.tracks.iter())
.chain(self.playlist_detail.iter().flat_map(|pt| pt.tracks.iter()))
.chain(self.genre_songs.iter())
.chain(self.search_results.iter().flat_map(|r| r.songs.iter()))
.chain(self.similar_results.iter().map(|m| &m.song))
.chain(self.favorites.iter().flat_map(|f| f.songs.iter()))
.find(|s| s.id == id)
}

pub(crate) fn current_user_rating(&self, id: &str) -> Option<u32> {
self.find_song_for_rating(id).and_then(|s| s.user_rating)
}

/// Applies `f` to every in-memory copy of this song across the rating-bearing collections
pub(crate) fn for_each_rated_song_mut(&mut self, id: &str, mut f: impl FnMut(&mut firmium_backend::commands::mappers::Song)) {
if let Some(at) = self.album_detail.as_mut() {
for s in at.tracks.iter_mut() {
if s.id == id { f(s); }
}
}
if let Some(pt) = self.playlist_detail.as_mut() {
for s in pt.tracks.iter_mut() {
if s.id == id { f(s); }
}
}
for s in self.genre_songs.iter_mut() {
if s.id == id { f(s); }
}
if let Some(res) = self.search_results.as_mut() {
for s in res.songs.iter_mut() {
if s.id == id { f(s); }
}
}
for m in self.similar_results.iter_mut() {
if m.song.id == id { f(&mut m.song); }
}
if let Some(fav) = self.favorites.as_mut() {
for s in fav.songs.iter_mut() {
if s.id == id { f(s); }
}
}
}

pub(crate) fn update_average_rating_locally(&mut self, id: &str, avg: Option<f32>) {
self.for_each_rated_song_mut(id, |s| s.average_rating = avg);
}

pub(crate) fn cover_image(&self, cover_id: Option<&str>, size: f32) -> Element<'_, Message> {
let t = self.tokens;
let radius = if size >= 80.0 { 14.0_f32 } else if size >= 40.0 { 10.0 } else { 6.0 };
Expand Down Expand Up @@ -176,6 +277,9 @@ pub(crate) const ARTIST_ROW_H: f32 = 60.0;
pub(crate) const TRACK_ROW_H: f32 = 52.0;
pub(crate) const VIEWPORT_H: f32 = 640.0;

/// Card width + spacing for the horizontal Favorites album shelf
pub(crate) const ALBUM_CARD_W: f32 = 170.0;

/// Max number of decoded cover-art image handles kept in memory at once.
pub(crate) const MAX_COVER_HANDLES: usize = 512;

Expand Down
3 changes: 3 additions & 0 deletions desktop/src/app/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,14 @@ pub enum Message {
ArtistsScrolled(f32),
AlbumTracksScrolled(f32),
PlaylistTracksScrolled(f32),
FavoritesSongsScrolled(f32),
FavoritesAlbumsScrolled(f32),
PlayAlbumAt(usize),
PlayPlaylistAt(usize),
ShuffleAlbum,
PlaySong(Song),
SetRating(String, u32),
RatingRefreshed(String, u32, Option<u32>, Result<Song, UserError>),
ToggleStar(String, StarKind),
StarToggled(String, StarKind, Result<bool, UserError>),
FavoritesLoaded(Result<Starred, UserError>),
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ pub struct App {
album_detail: Option<AlbumTracks>,
album_detail_id: Option<String>,
favorites: Option<firmium_backend::commands::mappers::Starred>,
favorites_songs_scroll: f32,
favorites_albums_scroll: f32,
album_tracks_scroll: f32,
artists: Vec<Artist>,
artists_scroll: f32,
Expand Down Expand Up @@ -298,6 +300,8 @@ impl App {
album_detail: None,
album_detail_id: None,
favorites: None,
favorites_songs_scroll: 0.0,
favorites_albums_scroll: 0.0,
album_tracks_scroll: 0.0,
artists: Vec::new(),
artists_scroll: 0.0,
Expand Down
19 changes: 14 additions & 5 deletions desktop/src/app/styles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,19 +321,28 @@ pub(crate) fn thin_scrollbar() -> scrollable::Scrollbar {
}

pub(crate) fn thin_scroll_style(t: Tokens) -> impl Fn(&Theme, scrollable::Status) -> scrollable::Style {
move |_, _| {
let rail = scrollable::Rail {
move |_, status| {
let (h_active, v_active) = match status {
scrollable::Status::Hovered { is_horizontal_scrollbar_hovered, is_vertical_scrollbar_hovered, .. } => {
(is_horizontal_scrollbar_hovered, is_vertical_scrollbar_hovered)
}
scrollable::Status::Dragged { is_horizontal_scrollbar_dragged, is_vertical_scrollbar_dragged, .. } => {
(is_horizontal_scrollbar_dragged, is_vertical_scrollbar_dragged)
}
scrollable::Status::Active { .. } => (false, false),
};
let rail = |active: bool| scrollable::Rail {
background: Some(Background::Color(Color { a: 0.08, ..t.muted })),
border: Border { radius: 3.0.into(), ..Border::default() },
scroller: scrollable::Scroller {
background: Background::Color(Color { a: 0.55, ..t.muted }),
background: Background::Color(if active { t.accent } else { Color { a: 0.55, ..t.muted } }),
border: Border { radius: 3.0.into(), ..Border::default() },
},
};
scrollable::Style {
container: container::Style::default(),
vertical_rail: rail,
horizontal_rail: rail,
vertical_rail: rail(v_active),
horizontal_rail: rail(h_active),
gap: None,
auto_scroll: scrollable::AutoScroll {
background: Background::Color(t.surface),
Expand Down
78 changes: 48 additions & 30 deletions desktop/src/app/update/library.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use iced::Task;


use super::super::cover::{ALBUM_ROW_H, load_rounded_cover};
use super::super::cover::{ALBUM_CARD_W, ALBUM_ROW_H, TRACK_ROW_H, VIEWPORT_H, load_rounded_cover};
use super::super::message::Message;
use super::super::types::*;
use super::super::App;
Expand Down Expand Up @@ -56,6 +56,22 @@ impl App {
self.album_tracks_scroll = y;
Task::none()
}
Message::FavoritesSongsScrolled(y) => {
self.favorites_songs_scroll = y;
let Some(starred) = &self.favorites else { return Task::none(); };
let first = ((y / TRACK_ROW_H).floor().max(0.0) as usize).min(starred.songs.len());
let end = (first + 16).min(starred.songs.len());
let ids: Vec<String> = starred.songs[first..end].iter().filter_map(|s| s.cover_art_id.clone()).collect();
self.load_cover_ids(ids)
}
Message::FavoritesAlbumsScrolled(x) => {
self.favorites_albums_scroll = x;
let Some(starred) = &self.favorites else { return Task::none(); };
let first = ((x / ALBUM_CARD_W).floor().max(0.0) as usize).min(starred.albums.len());
let end = (first + 8).min(starred.albums.len());
let ids: Vec<String> = starred.albums[first..end].iter().filter_map(|a| a.cover_art_id.clone()).collect();
self.load_cover_ids(ids)
}
Message::AlbumTracksLoaded(Ok(at)) => {
let ids: Vec<String> = at
.cover_art_id
Expand Down Expand Up @@ -153,37 +169,31 @@ impl App {
),
Message::SetRating(id, rating) => {
// Optimistic local update so the stars fill immediately.
if let Some(at) = &mut self.album_detail {
for s in &mut at.tracks {
if s.id == id {
s.user_rating = Some(rating);
}
}
}
if let Some(pt) = &mut self.playlist_detail {
for s in &mut pt.tracks {
if s.id == id {
s.user_rating = Some(rating);
}
}
}
if let Some(res) = &mut self.search_results {
for s in &mut res.songs {
if s.id == id {
s.user_rating = Some(rating);
}
}
}
for m in &mut self.similar_results {
if m.song.id == id {
m.song.user_rating = Some(rating);
}
}
// Remember what it was so a failed write (RatingRefreshed
// below) can be reverted, same as ToggleStar/StarToggled.
let previous_rating = self.current_user_rating(&id);
self.for_each_rated_song_mut(&id, |s| s.user_rating = Some(rating));
let temp_id = id.clone();
Task::perform(
firmium_backend::commands::subsonic::set_rating(self.backend.app_state.clone(), id, rating),
|_| Message::DownloadDone(Ok(())),
firmium_backend::commands::subsonic::set_rating_and_refetch(self.backend.app_state.clone(), id, rating),
move |result| Message::RatingRefreshed(temp_id.clone(), rating, previous_rating, result),
)
}
Message::RatingRefreshed(id, _rating, _previous, Ok(song)) => {
// Only revert if nothing newer has superseded this attempt
if self.current_user_rating(&id) == song.user_rating {
self.update_average_rating_locally(&id, song.average_rating);
}
Task::none()
}
Message::RatingRefreshed(id, rating, previous, Err(e)) => {
// Only revert if nothing newer has superseded this attempt
if self.current_user_rating(&id) == Some(rating) {
self.for_each_rated_song_mut(&id, |s| s.user_rating = previous);
}
self.show_toast(e);
Task::none()
}
Message::ToggleStar(id, kind) => {
// Optimistic: figure out current starred state from whichever
// in-memory collection holds this id, flip it locally, then
Expand Down Expand Up @@ -213,8 +223,16 @@ impl App {
Task::none()
}
Message::FavoritesLoaded(Ok(starred)) => {
// First screenful only, the rest load incrementally as each shelf scrolls
let albums_per_viewport = (VIEWPORT_H / ALBUM_CARD_W).ceil() as usize;
let songs_per_viewport = (VIEWPORT_H / TRACK_ROW_H).ceil() as usize;
let ids: Vec<String> = starred.albums.iter().take(albums_per_viewport).filter_map(|a| a.cover_art_id.clone())
.chain(starred.songs.iter().take(songs_per_viewport).filter_map(|s| s.cover_art_id.clone()))
.collect();
self.favorites = Some(starred);
Task::none()
self.favorites_songs_scroll = 0.0;
self.favorites_albums_scroll = 0.0;
self.load_cover_ids(ids)
}
Message::FavoritesLoaded(Err(_e)) => Task::none(),
Message::DownloadTrack(song) => Task::perform(
Expand Down
Loading