Fixed UI bugs
This commit is contained in:
+162
-3
@@ -29,7 +29,7 @@ slint::include_modules!();
|
||||
|
||||
mod render;
|
||||
use render::{
|
||||
breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model,
|
||||
breadcrumb_screen, contributor_lines, find_release_key, model, parse_artist_key,
|
||||
parse_track_key, release_artist_credits, render, render_catalog, render_current_track,
|
||||
render_playback, render_queue, render_search, render_shell, render_track_info,
|
||||
selected_release, track_context,
|
||||
@@ -99,6 +99,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
let screen = match target.as_str() {
|
||||
"search" => Screen::Search,
|
||||
"library" => Screen::Library,
|
||||
"history" => Screen::History,
|
||||
_ => Screen::Home,
|
||||
};
|
||||
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
|
||||
@@ -144,6 +145,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
"add-end" => UiAction::AddToEnd(vec![key]),
|
||||
"information" => UiAction::ShowTrackInfo(key),
|
||||
"playlist" => UiAction::ShowPlaylistPicker(key),
|
||||
"similar" => UiAction::SearchSimilar(key),
|
||||
// The remaining presentation actions terminate here until
|
||||
// their backend capabilities are introduced.
|
||||
_ => return,
|
||||
@@ -152,6 +154,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
}
|
||||
});
|
||||
bind_playlist_callbacks(window, state, backend);
|
||||
bind_queue_callbacks(window, state, backend);
|
||||
bind_device_callbacks(window, state, backend);
|
||||
bind_settings_callbacks(window, state, backend);
|
||||
window.on_dismiss_error({
|
||||
@@ -236,6 +239,9 @@ fn bind_playlist_callbacks(
|
||||
let backend = backend.clone();
|
||||
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker)
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_queue_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
|
||||
window.on_play_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
@@ -251,6 +257,41 @@ fn bind_playlist_callbacks(
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_move_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |id, target_index| {
|
||||
let (Ok(id), Ok(target_index)) = (id.parse::<u64>(), usize::try_from(target_index))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::MoveQueueItem {
|
||||
item_id: QueueItemId::new(id),
|
||||
target_index,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_remove_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |id| {
|
||||
if let Ok(id) = id.parse::<u64>() {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::RemoveQueueItem(QueueItemId::new(id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_catalog_callbacks(
|
||||
@@ -295,7 +336,7 @@ fn bind_catalog_callbacks(
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |target| {
|
||||
let screen = with_state(&state, |state| breadcrumb_screen(state, &target));
|
||||
let screen = breadcrumb_screen(&target);
|
||||
if let Some(screen) = screen {
|
||||
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
|
||||
}
|
||||
@@ -306,7 +347,7 @@ fn bind_catalog_callbacks(
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |key| {
|
||||
let artist = with_state(&state, |state| find_artist_key(state, &key));
|
||||
let artist = parse_artist_key(&key);
|
||||
if let Some(key) = artist {
|
||||
dispatch_action(
|
||||
&window,
|
||||
@@ -519,6 +560,7 @@ fn bind_settings_callbacks(
|
||||
);
|
||||
}
|
||||
});
|
||||
bind_similarity_settings_callbacks(window, state, backend);
|
||||
window.on_language_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
@@ -534,6 +576,118 @@ fn bind_settings_callbacks(
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one explicit binding per similarity setting keeps callback ownership obvious"
|
||||
)]
|
||||
fn bind_similarity_settings_callbacks(
|
||||
window: &AppWindow,
|
||||
state: &Arc<Mutex<AppState>>,
|
||||
backend: &BackendHandle,
|
||||
) {
|
||||
window.on_similarity_enabled_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |enabled| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityEnabledChanged(enabled),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_model_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |model| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityModelChanged(model.to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_profile_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |profile| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityProfileChanged(profile.to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_workers_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |workers| {
|
||||
if let Ok(workers) = usize::try_from(workers) {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityWorkersChanged(workers),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_similarity_minimum_score_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |minimum_score| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityMinimumScoreChanged(minimum_score),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_max_tracks_per_artist_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |maximum| {
|
||||
if let Ok(maximum) = usize::try_from(maximum) {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityMaxTracksPerArtistChanged(maximum),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_similarity_federation_consent_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |consent| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityFederationConsentChanged(consent),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_clear_similarity({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move || dispatch_action(&window, &state, &backend, UiAction::ClearSimilarity)
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
|
||||
window.on_choose_library_path({
|
||||
let window = window.as_weak();
|
||||
@@ -717,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|
||||
|| previous.federation_debug != state.backend.federation_debug
|
||||
|| previous.connected_devices != state.backend.connected_devices
|
||||
|| previous.settings != state.backend.settings
|
||||
|| previous.similarity_status != state.backend.similarity_status
|
||||
|| previous.playback_error != state.backend.playback_error
|
||||
|| previous.settings_error != state.backend.settings_error;
|
||||
let catalog_changed = previous.library != state.backend.library
|
||||
|| previous.search != state.backend.search
|
||||
|| previous.queue != state.backend.queue;
|
||||
let similarity_changed = previous.similarity_search != state.backend.similarity_search;
|
||||
let queue_changed = previous.queue != state.backend.queue;
|
||||
if shell_changed {
|
||||
render_shell(window, state);
|
||||
@@ -735,6 +891,9 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|
||||
render_queue(window, state);
|
||||
render_current_track(window, state);
|
||||
}
|
||||
if similarity_changed {
|
||||
render::render_similarity(window, state);
|
||||
}
|
||||
render_playback(window, state);
|
||||
});
|
||||
}
|
||||
|
||||
+211
-27
@@ -3,6 +3,7 @@ pub(super) fn render(window: &AppWindow, state: &AppState) {
|
||||
render_shell(window, state);
|
||||
render_catalog(window, state);
|
||||
render_search(window, state);
|
||||
render_similarity(window, state);
|
||||
render_queue(window, state);
|
||||
render_current_track(window, state);
|
||||
render_playback(window, state);
|
||||
@@ -15,7 +16,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
window.set_search_label(strings.search.into());
|
||||
window.set_library_label(strings.library.into());
|
||||
window.set_queue_label(strings.queue.into());
|
||||
window.set_recent_label(strings.recently_played.into());
|
||||
window.set_history_label(strings.listening_history.into());
|
||||
window.set_featured_label(strings.made_for_listening.into());
|
||||
window.set_search_placeholder(strings.search_placeholder.into());
|
||||
window.set_active_screen(
|
||||
@@ -23,6 +24,8 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
Screen::Home => "home",
|
||||
Screen::Search => "search",
|
||||
Screen::Library => "library",
|
||||
Screen::History => "history",
|
||||
Screen::Similarity => "similarity",
|
||||
Screen::Artist(_) => "artist",
|
||||
Screen::Release(_, _) => "release",
|
||||
Screen::Playlist(_) => "playlist",
|
||||
@@ -40,6 +43,46 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
window.set_library_path(state.backend.settings.library_path.clone().into());
|
||||
window.set_federation_enabled(state.backend.settings.federation_enabled);
|
||||
window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen);
|
||||
let similarity = &state.backend.settings.similarity;
|
||||
window.set_similarity_enabled(similarity.enabled);
|
||||
window.set_similarity_model(similarity.model.clone().into());
|
||||
window.set_similarity_models(model(vec![SharedString::from(
|
||||
"discogs-effnet-bsdynamic-1",
|
||||
)]));
|
||||
window.set_similarity_profile(similarity.profile.clone().into());
|
||||
window.set_similarity_profiles(model(vec![SharedString::from("furumi-full-track-v1")]));
|
||||
window.set_similarity_workers(i32::try_from(similarity.workers).unwrap_or(16));
|
||||
window.set_similarity_minimum_score(similarity.minimum_score);
|
||||
window.set_similarity_max_tracks_per_artist(
|
||||
i32::try_from(similarity.max_tracks_per_artist).unwrap_or(50),
|
||||
);
|
||||
window.set_similarity_federation_consent(similarity.federation_consent);
|
||||
let status = &state.backend.similarity_status;
|
||||
window.set_similarity_status_phase(status.phase.clone().into());
|
||||
window.set_similarity_status_progress(
|
||||
format!("{} / {}", status.completed_tracks, status.total_tracks).into(),
|
||||
);
|
||||
window.set_similarity_status_storage(
|
||||
format!(
|
||||
"{} / {}",
|
||||
status.stored_vectors,
|
||||
compact_bytes(status.stored_bytes)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
window.set_similarity_status_current(
|
||||
status
|
||||
.current_track
|
||||
.clone()
|
||||
.or_else(|| status.error.clone())
|
||||
.unwrap_or_else(|| {
|
||||
status.active_profile.as_deref().map_or_else(
|
||||
|| "Index is not ready".into(),
|
||||
|profile| format!("Active: {profile}"),
|
||||
)
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
window.set_selected_language(state.backend.settings.language.clone().into());
|
||||
window.set_available_languages(model(vec![SharedString::from("English")]));
|
||||
window.set_search_query(state.frontend.search_query.clone().into());
|
||||
@@ -47,6 +90,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
render_device_shell(window, state);
|
||||
let (network_busy, network_text) = federation_status(state);
|
||||
window.set_federation_status_busy(network_busy);
|
||||
window.set_federation_status_running(state.backend.federation_debug.running);
|
||||
window.set_federation_status_text(network_text.into());
|
||||
render_federation_debug(window, state);
|
||||
render_build_info(window, state);
|
||||
@@ -62,6 +106,18 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
render_track_info(window, state);
|
||||
}
|
||||
|
||||
fn compact_bytes(bytes: u64) -> String {
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = 1024 * KIB;
|
||||
if bytes >= MIB {
|
||||
format!("{}.{:01} MiB", bytes / MIB, bytes % MIB * 10 / MIB)
|
||||
} else if bytes >= KIB {
|
||||
format!("{}.{:01} KiB", bytes / KIB, bytes % KIB * 10 / KIB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn render_federation_debug(window: &AppWindow, state: &AppState) {
|
||||
let debug = &state.backend.federation_debug;
|
||||
window.set_federation_debug_node(
|
||||
@@ -184,13 +240,27 @@ fn render_device_shell(window: &AppWindow, state: &AppState) {
|
||||
devices
|
||||
.pending_pairings
|
||||
.iter()
|
||||
.map(|pending| PairingView {
|
||||
request_id: pending.request_id.clone().into(),
|
||||
name: pending.name.clone().into(),
|
||||
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
|
||||
.into(),
|
||||
group_conflict: pending.requester_group_id.is_some()
|
||||
&& pending.requester_group_active_devices > 1,
|
||||
.map(|pending| {
|
||||
let group_conflict = pending.requester_group_id.is_some()
|
||||
&& pending.requester_group_active_devices > 1;
|
||||
PairingView {
|
||||
request_id: pending.request_id.clone().into(),
|
||||
name: pending.name.clone().into(),
|
||||
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
|
||||
.into(),
|
||||
group_conflict,
|
||||
group_summary: pending
|
||||
.requester_group_id
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |group_id| {
|
||||
format!(
|
||||
"Their group · {} active devices · {}",
|
||||
pending.requester_group_active_devices,
|
||||
group_id.chars().take(24).collect::<String>()
|
||||
)
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
@@ -403,12 +473,18 @@ fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> {
|
||||
.flat_map(|library| library.featured_releases.iter())
|
||||
.flat_map(|release| release.tracks.iter()),
|
||||
)
|
||||
.chain(
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.recently_played.iter()),
|
||||
)
|
||||
.chain(
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.playlists.iter())
|
||||
.flat_map(|playlist| playlist.tracks.iter()),
|
||||
)
|
||||
.chain(state.backend.similarity_search.results.iter())
|
||||
.find(|track| track.key.matches(key))
|
||||
}
|
||||
|
||||
@@ -462,11 +538,12 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
|
||||
let tracks: Vec<Track> = match context {
|
||||
"release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks),
|
||||
"search" => state.backend.search.results.tracks.clone(),
|
||||
"recent" => ready_library(state)
|
||||
"history" => ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.recently_played.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
"similarity" => state.backend.similarity_search.results.clone(),
|
||||
"artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| {
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
@@ -492,17 +569,18 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
|
||||
tracks.into_iter().map(|track| track.key).collect()
|
||||
}
|
||||
|
||||
pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option<Screen> {
|
||||
pub(super) fn breadcrumb_screen(target: &SharedString) -> Option<Screen> {
|
||||
match target.as_str() {
|
||||
"home" => Some(Screen::Home),
|
||||
"search" => Some(Screen::Search),
|
||||
"library" => Some(Screen::Library),
|
||||
"history" => Some(Screen::History),
|
||||
value if value.starts_with("playlist:") => value
|
||||
.trim_start_matches("playlist:")
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.map(Screen::Playlist),
|
||||
_ => find_artist_key(state, target).map(Screen::Artist),
|
||||
_ => parse_artist_key(target).map(Screen::Artist),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +604,14 @@ fn breadcrumbs(state: &AppState) -> Vec<BreadcrumbView> {
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Library".into(), String::new(), false),
|
||||
],
|
||||
Screen::History => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Listening history".into(), String::new(), false),
|
||||
],
|
||||
Screen::Similarity => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Similar tracks".into(), String::new(), false),
|
||||
],
|
||||
Screen::Artist(_) => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb(
|
||||
@@ -588,7 +674,11 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
|
||||
});
|
||||
window.set_releases(model(releases));
|
||||
let artists = library.map_or_else(Vec::new, |library| {
|
||||
library.artists.iter().map(artist_to_view).collect()
|
||||
library
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| artist_to_view(artist, &library.featured_releases))
|
||||
.collect()
|
||||
});
|
||||
window.set_artists(model(artists));
|
||||
|
||||
@@ -660,10 +750,10 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
|
||||
|
||||
render_catalog_detail(window, state, selected_artist);
|
||||
|
||||
let recent = library.map_or_else(Vec::new, |library| {
|
||||
let history = library.map_or_else(Vec::new, |library| {
|
||||
tracks_to_views(&library.recently_played, state)
|
||||
});
|
||||
window.set_tracks(model(recent));
|
||||
window.set_history_tracks(model(history));
|
||||
let playlist = selected_playlist(state);
|
||||
window.set_playlist_title(
|
||||
playlist
|
||||
@@ -741,6 +831,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
|
||||
let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref());
|
||||
QueueView {
|
||||
key: item.id.get().to_string().into(),
|
||||
track_key: format_track_key(&item.track.key).into(),
|
||||
title: item.track.title.clone().into(),
|
||||
artist: item.track.artist.clone().into(),
|
||||
artist_key: item
|
||||
@@ -753,6 +844,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
|
||||
release: item.track.release.clone().into(),
|
||||
release_key: format_release_key(&item.track.release_id).into(),
|
||||
active: state.backend.queue.current_index() == Some(index),
|
||||
liked: item.track.liked,
|
||||
artwork,
|
||||
has_artwork,
|
||||
}
|
||||
@@ -809,7 +901,12 @@ pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
|
||||
pub(super) fn render_search(window: &AppWindow, state: &AppState) {
|
||||
let search = &state.backend.search;
|
||||
window.set_search_artists(model(
|
||||
search.results.artists.iter().map(artist_to_view).collect(),
|
||||
search
|
||||
.results
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| artist_to_view(artist, &search.results.releases))
|
||||
.collect(),
|
||||
));
|
||||
window.set_search_releases(model(
|
||||
search
|
||||
@@ -822,6 +919,14 @@ pub(super) fn render_search(window: &AppWindow, state: &AppState) {
|
||||
window.set_search_results(model(tracks_to_views(&search.results.tracks, state)));
|
||||
}
|
||||
|
||||
pub(super) fn render_similarity(window: &AppWindow, state: &AppState) {
|
||||
let search = &state.backend.similarity_search;
|
||||
window.set_similarity_source_title(search.source_title.clone().into());
|
||||
window.set_similarity_pending(search.pending);
|
||||
window.set_similarity_error(search.error.clone().unwrap_or_default().into());
|
||||
window.set_similarity_tracks(model(tracks_to_views(&search.results, state)));
|
||||
}
|
||||
|
||||
fn search_duration_label(milliseconds: u64) -> String {
|
||||
if milliseconds < 1_000 {
|
||||
format!("{milliseconds} ms")
|
||||
@@ -1028,8 +1133,13 @@ pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec<Artist
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn artist_to_view(artist: &Artist) -> ArtistView {
|
||||
let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref());
|
||||
fn artist_to_view(artist: &Artist, releases: &[Release]) -> ArtistView {
|
||||
let artwork_uri = artist
|
||||
.artwork
|
||||
.uri
|
||||
.as_deref()
|
||||
.or_else(|| fallback_artist_artwork(artist, releases));
|
||||
let (artwork, has_artwork) = load_artwork(artwork_uri);
|
||||
ArtistView {
|
||||
key: format_artist_key(&artist.key).into(),
|
||||
name: artist.name.clone().into(),
|
||||
@@ -1044,6 +1154,30 @@ fn artist_to_view(artist: &Artist) -> ArtistView {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_artist_artwork<'a>(artist: &Artist, releases: &'a [Release]) -> Option<&'a str> {
|
||||
let belongs_to_artist = |release: &&Release| {
|
||||
release
|
||||
.artists
|
||||
.iter()
|
||||
.any(|candidate| candidate.key == artist.key)
|
||||
&& release.artwork.uri.is_some()
|
||||
};
|
||||
let mut candidates = releases
|
||||
.iter()
|
||||
.filter(|release| release.is_album())
|
||||
.filter(belongs_to_artist)
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
candidates.extend(releases.iter().filter(belongs_to_artist));
|
||||
}
|
||||
let hash = artist.name.bytes().fold(0_usize, |hash, byte| {
|
||||
hash.wrapping_mul(31) ^ usize::from(byte)
|
||||
});
|
||||
candidates
|
||||
.get(hash % candidates.len().max(1))
|
||||
.and_then(|release| release.artwork.uri.as_deref())
|
||||
}
|
||||
|
||||
fn release_to_view(release: &Release) -> ReleaseView {
|
||||
let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref());
|
||||
ReleaseView {
|
||||
@@ -1296,16 +1430,21 @@ fn format_release_key(key: &ReleaseKey) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option<ArtistKey> {
|
||||
let local = match &state.backend.library {
|
||||
RemoteData::Ready(library) => library.artists.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
local
|
||||
.iter()
|
||||
.chain(state.backend.search.results.artists.iter())
|
||||
.find(|artist| format_artist_key(&artist.key) == value.as_str())
|
||||
.map(|artist| artist.key.clone())
|
||||
pub(super) fn parse_artist_key(value: &SharedString) -> Option<ArtistKey> {
|
||||
if let Some(local) = value.strip_prefix("local-artist:") {
|
||||
return local
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.map(|id| ArtistKey::local(furumi_domain::ArtistId::new(id)));
|
||||
}
|
||||
let (peer_id, id) = value.strip_prefix("fed-artist:")?.split_once(':')?;
|
||||
if peer_id.is_empty() || id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ArtistKey::Federation {
|
||||
peer_id: peer_id.into(),
|
||||
id: id.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option<ReleaseKey> {
|
||||
@@ -1380,6 +1519,51 @@ mod tests {
|
||||
assert_eq!(decoded.size().height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_link_key_is_resolved_without_a_top_level_artist_row() {
|
||||
let expected = ArtistKey::Federation {
|
||||
peer_id: "peer-a".into(),
|
||||
id: "guest:artist".into(),
|
||||
};
|
||||
let encoded: SharedString = format_artist_key(&expected).into();
|
||||
|
||||
assert_eq!(parse_artist_key(&encoded), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_without_an_image_uses_one_of_its_album_covers() {
|
||||
let key = ArtistKey::local(furumi_domain::ArtistId::new(12));
|
||||
let artist = Artist {
|
||||
key: key.clone(),
|
||||
source: CatalogSource::Local,
|
||||
name: "Artist".into(),
|
||||
artwork: furumi_domain::Artwork::default(),
|
||||
release_count: 1,
|
||||
track_count: 1,
|
||||
};
|
||||
let release = Release {
|
||||
key: ReleaseKey::local(furumi_domain::ReleaseId::new(8)),
|
||||
source: CatalogSource::Local,
|
||||
title: "Album".into(),
|
||||
artists: vec![ArtistRef {
|
||||
key,
|
||||
name: "Artist".into(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
artwork: furumi_domain::Artwork {
|
||||
uri: Some("/music/album-cover.jpg".into()),
|
||||
},
|
||||
tracks: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
fallback_artist_artwork(&artist, &[release]),
|
||||
Some("/music/album-cover.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_credits_include_artists_found_only_on_tracks() {
|
||||
let pasha = ArtistRef {
|
||||
|
||||
Reference in New Issue
Block a user