Files
furumi-desktop/crates/ui/src/render.rs
T
2026-08-13 11:55:35 +01:00

1650 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(super) fn render(window: &AppWindow, state: &AppState) {
render_shell(window, state);
render_catalog(window, state);
render_search(window, state);
render_queue(window, state);
render_current_track(window, state);
render_playback(window, state);
}
pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
let strings = state.frontend.locale.strings();
window.set_app_name(strings.app_name.into());
window.set_home_label(strings.home.into());
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_featured_label(strings.made_for_listening.into());
window.set_search_placeholder(strings.search_placeholder.into());
window.set_active_screen(
match &state.frontend.screen {
Screen::Home => "home",
Screen::Search => "search",
Screen::Library => "library",
Screen::Artist(_) => "artist",
Screen::Release(_, _) => "release",
Screen::Playlist(_) => "playlist",
}
.into(),
);
window.set_can_go_back(!state.frontend.navigation_history.is_empty());
window.set_can_go_forward(!state.frontend.navigation_forward.is_empty());
window.set_breadcrumbs(model(breadcrumbs(state)));
window.set_queue_open(state.frontend.queue_open);
window.set_settings_open(state.frontend.settings_open);
window.set_playlist_picker_open(state.frontend.playlist_picker_track.is_some());
window.set_network_id(state.backend.settings.network_id.clone().into());
window.set_device_name(state.backend.settings.device_name.clone().into());
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);
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());
render_playlist_shell(window, state);
render_device_shell(window, state);
let (network_busy, network_text) = federation_status(state);
window.set_federation_status_busy(network_busy);
window.set_federation_status_text(network_text.into());
render_federation_debug(window, state);
render_build_info(window, state);
window.set_error_message(
state
.transient_error
.clone()
.or_else(|| state.backend.playback_error.clone())
.or_else(|| state.backend.settings_error.clone())
.unwrap_or_default()
.into(),
);
render_track_info(window, state);
}
fn render_federation_debug(window: &AppWindow, state: &AppState) {
let debug = &state.backend.federation_debug;
window.set_federation_debug_node(
if debug.running {
"Running"
} else if debug.error.is_some() {
"Error"
} else {
"Stopped"
}
.into(),
);
window.set_federation_debug_peers(
format!(
"{} connected · {} known",
debug.connected_peers, debug.known_contacts
)
.into(),
);
window.set_federation_debug_dht(
debug
.stored_dht_records
.map_or_else(|| "n/a".into(), |records| format!("{records} records"))
.into(),
);
window.set_federation_debug_published(format!("{} items", debug.published_items).into());
window.set_federation_debug_endpoint(
federation_node_ids(&debug.endpoint_id, &debug.dht_node_id).into(),
);
}
fn render_build_info(window: &AppWindow, state: &AppState) {
window.set_software_versions(model(
state
.backend
.build_info
.software
.iter()
.map(|entry| VersionView {
name: entry.name.clone().into(),
version: entry.version.clone().into(),
})
.collect(),
));
window.set_protocol_versions(model(
state
.backend
.build_info
.protocols
.iter()
.map(|entry| VersionView {
name: entry.name.clone().into(),
version: entry.version.clone().into(),
})
.collect(),
));
}
fn render_playlist_shell(window: &AppWindow, state: &AppState) {
let playlists = ready_library(state)
.map(|library| {
library
.playlists
.iter()
.map(|playlist| PlaylistView {
id: playlist.id.to_string().into(),
title: playlist.title.clone().into(),
details: format!(
"{} {}",
playlist.tracks.len(),
if playlist.tracks.len() == 1 {
"track"
} else {
"tracks"
}
)
.into(),
is_likes: playlist.is_likes,
})
.collect()
})
.unwrap_or_default();
window.set_playlists(model(playlists));
}
fn render_device_shell(window: &AppWindow, state: &AppState) {
let devices = &state.backend.connected_devices;
window.set_connected_devices(model(
devices
.devices
.iter()
.filter(|device| device.trust != DeviceTrust::Revoked)
.map(|device| DeviceView {
id: device.id.clone().into(),
name: device.name.clone().into(),
details: format!(
"{} · {}",
if device.presence == DevicePresence::Online {
"online"
} else {
"offline"
},
if device.trust == DeviceTrust::Revoked {
"revoked".to_string()
} else if device.client_version.is_empty() {
"Furumi".to_string()
} else {
format!("v{}", device.client_version)
}
)
.into(),
is_self: device.is_self,
online: device.presence == DevicePresence::Online,
active: device.is_active,
revoked: device.trust == DeviceTrust::Revoked,
})
.collect(),
));
window.set_pending_pairings(model(
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,
})
.collect(),
));
window.set_device_role_label(
match devices.role {
DevicePlaybackRole::Active => "Active device",
DevicePlaybackRole::Control => "Control device",
}
.into(),
);
window.set_device_is_active(devices.role == DevicePlaybackRole::Active);
window.set_active_device_label(devices.active_device_name.clone().into());
window.set_device_group_label(format!("Network · {}", devices.group_id).into());
window.set_device_invite(devices.invite.clone().unwrap_or_default().into());
window.set_device_busy(devices.busy);
window.set_device_status(
devices
.error
.clone()
.or_else(|| devices.last_sync.clone())
.unwrap_or_default()
.into(),
);
}
pub(super) fn render_track_info(window: &AppWindow, state: &AppState) {
let track = state
.frontend
.track_info
.as_ref()
.and_then(|key| find_track(state, key));
window.set_track_info_open(track.is_some());
window.set_track_info_title(
track
.map_or_else(String::new, |track| track.title.clone())
.into(),
);
window.set_track_info_artist_links(model(track.map_or_else(Vec::new, track_artist_links)));
window.set_track_info_release(
track
.map_or_else(String::new, |track| {
let mut label = track.release.clone();
if let Some(disc) = track.disc_number {
let _ = write!(label, " · disc {disc}");
}
if let Some(number) = track.track_number {
let _ = write!(label, " · track {number}");
}
label
})
.into(),
);
window.set_track_info_duration(
track
.map_or_else(String::new, |track| {
format!(
"{} ({:.2} s)",
duration_label(track.duration_seconds),
track.duration_seconds
)
})
.into(),
);
window.set_track_info_format(
track
.and_then(|track| track.audio_format.clone())
.unwrap_or_default()
.to_uppercase()
.into(),
);
window.set_track_info_quality(track.map(track_quality_label).unwrap_or_default().into());
window.set_track_info_source(
track
.map_or_else(String::new, |track| match track.audio_source {
AudioSource::LocalFile(_) => "Local library".into(),
AudioSource::Federation { .. } => "Federation".into(),
})
.into(),
);
window.set_track_info_content_id(
track
.and_then(|track| track.key.content_id())
.map_or_else(String::new, |id| id.as_str().to_owned())
.into(),
);
window.set_track_info_path(
track
.map_or_else(String::new, |track| match &track.audio_source {
AudioSource::LocalFile(path) => path.to_string_lossy().into_owned(),
AudioSource::Federation { peer_id, .. } => format!("peer {peer_id}"),
})
.into(),
);
let (artwork, has_artwork) = load_artwork(track.and_then(|track| track.cover_uri.as_deref()));
window.set_track_info_artwork(artwork);
window.set_track_info_has_artwork(has_artwork);
}
fn track_quality_label(track: &Track) -> String {
let mut fields = Vec::new();
if let Some(bitrate) = track.audio_bitrate_kbps {
fields.push(format!("{bitrate} kbps"));
}
if let Some(rate) = track.audio_sample_rate_hz {
fields.push(format!("{:.1} kHz", f64::from(rate) / 1_000.0));
}
if let Some(depth) = track.audio_bit_depth {
fields.push(format!("{depth} bit"));
}
if let Some(size) = track.file_size_bytes {
let whole = size / 1_048_576;
let decimal = size % 1_048_576 * 10 / 1_048_576;
fields.push(format!("{whole}.{decimal} MiB"));
}
fields.join(" · ")
}
fn sync_media_session(state: &AppState) {
MEDIA_SESSION.with_borrow_mut(|session| {
let Some(session) = session.as_mut() else {
return;
};
if let Some(track) = state.backend.queue.current().map(|item| &item.track) {
session.update_metadata(
&track.title,
&track.artist,
&track.release,
track.duration_seconds,
);
}
session.update_playback(
state.backend.playback.status != PlaybackStatus::Stopped,
state.backend.playback.status == PlaybackStatus::Paused,
state.backend.playback.position_seconds,
);
});
}
fn federation_status(state: &AppState) -> (bool, String) {
if !state.backend.settings.federation_enabled {
return (false, "Federation disabled".into());
}
let activity = &state.backend.federation_activity;
let operation = match activity.operation {
FederationOperation::Idle => "Connecting to federation",
FederationOperation::Search => "Searching federation",
FederationOperation::Artist => "Loading artist",
FederationOperation::Release => "Loading release",
};
if activity.pending {
return (true, format!("{operation}…"));
}
if let Some(error) = &activity.error {
return (false, format!("Network error · {error}"));
}
if let Some(stats) = &activity.stats {
return (
false,
format!(
"{} tracks · {} artists · {} peers · {}",
stats.tracks,
stats.artists,
stats.peers_queried,
search_duration_label(stats.duration_ms)
),
);
}
(false, "Federation ready".into())
}
fn federation_node_ids(endpoint_id: &str, dht_node_id: &str) -> String {
fn short(value: &str) -> &str {
value.get(..12).unwrap_or(value)
}
match (endpoint_id.is_empty(), dht_node_id.is_empty()) {
(false, false) => format!("ep {} · dht {}", short(endpoint_id), short(dht_node_id)),
(false, true) => format!("ep {}", short(endpoint_id)),
(true, false) => format!("dht {}", short(dht_node_id)),
(true, true) => "n/a".into(),
}
}
fn ready_library(state: &AppState) -> Option<&furumi_backend_api::LibrarySnapshot> {
match &state.backend.library {
RemoteData::Ready(library) => Some(library),
_ => None,
}
}
fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> {
state
.backend
.queue
.items()
.iter()
.map(|item| &item.track)
.chain(state.backend.search.results.tracks.iter())
.chain(
state
.backend
.search
.results
.releases
.iter()
.flat_map(|release| release.tracks.iter()),
)
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.featured_releases.iter())
.flat_map(|release| release.tracks.iter()),
)
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.playlists.iter())
.flat_map(|playlist| playlist.tracks.iter()),
)
.find(|track| track.key.matches(key))
}
fn selected_artist(state: &AppState) -> Option<&Artist> {
let Screen::Artist(key) = &state.frontend.screen else {
return None;
};
state
.backend
.search
.results
.artists
.iter()
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.artists.iter()),
)
.find(|artist| &artist.key == key)
}
pub(super) fn selected_release(state: &AppState) -> Option<Release> {
let Screen::Release(key, _) = &state.frontend.screen else {
return None;
};
let mut candidates = ready_library(state)
.into_iter()
.flat_map(|library| library.featured_releases.iter())
.chain(state.backend.search.results.releases.iter())
.filter(|release| &release.key == key)
.cloned();
let mut selected = candidates.next()?;
for candidate in candidates {
merge_release_availability(&mut selected, candidate);
}
sort_release_tracks(&mut selected.tracks);
Some(selected)
}
fn selected_playlist(state: &AppState) -> Option<&furumi_backend_api::PlaylistSnapshot> {
let Screen::Playlist(id) = state.frontend.screen else {
return None;
};
ready_library(state)?
.playlists
.iter()
.find(|playlist| playlist.id == id)
}
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)
.into_iter()
.flat_map(|library| library.recently_played.iter())
.cloned()
.collect(),
"artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| {
ready_library(state)
.into_iter()
.flat_map(|library| library.featured_releases.iter())
.flat_map(|release| release.tracks.iter())
.chain(state.backend.search.results.tracks.iter())
.filter(|track| {
track
.featured_artists
.iter()
.any(|candidate| candidate.key == artist.key)
})
.cloned()
.collect()
}),
"playlist" => selected_playlist(state)
.into_iter()
.flat_map(|playlist| playlist.tracks.iter())
.cloned()
.collect(),
_ => Vec::new(),
};
tracks.into_iter().map(|track| track.key).collect()
}
pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option<Screen> {
match target.as_str() {
"home" => Some(Screen::Home),
"search" => Some(Screen::Search),
"library" => Some(Screen::Library),
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),
}
}
fn breadcrumbs(state: &AppState) -> Vec<BreadcrumbView> {
let crumb = |label: String, target: String, trailing: bool| BreadcrumbView {
label: if trailing {
format!("{label} ")
} else {
label
}
.into(),
target: target.into(),
};
match &state.frontend.screen {
Screen::Home => vec![crumb("Home".into(), String::new(), false)],
Screen::Search => vec![
crumb("Home".into(), "home".into(), true),
crumb("Search".into(), String::new(), false),
],
Screen::Library => vec![
crumb("Home".into(), "home".into(), true),
crumb("Library".into(), String::new(), false),
],
Screen::Artist(_) => vec![
crumb("Home".into(), "home".into(), true),
crumb(
selected_artist(state)
.map_or("Artist", |artist| artist.name.as_str())
.to_owned(),
String::new(),
false,
),
],
Screen::Release(_, preferred) => {
let release = selected_release(state);
let release = release.as_ref();
let artist = preferred
.as_ref()
.and_then(|key| {
release.and_then(|release| {
release.artists.iter().find(|artist| &artist.key == key)
})
})
.or_else(|| release.and_then(|release| release.artists.first()));
let mut result = vec![crumb("Home".into(), "home".into(), true)];
if let Some(artist) = artist {
result.push(crumb(
artist.name.clone(),
format_artist_key(&artist.key),
true,
));
}
result.push(crumb(
release
.map_or("Release", |release| release.title.as_str())
.to_owned(),
String::new(),
false,
));
result
}
Screen::Playlist(_) => vec![
crumb("Home".into(), "home".into(), true),
crumb(
selected_playlist(state)
.map_or("Playlist", |playlist| playlist.title.as_str())
.to_owned(),
String::new(),
false,
),
],
}
}
pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
let library = ready_library(state);
let releases = library.map_or_else(Vec::new, |library| {
library
.featured_releases
.iter()
.map(release_to_view)
.collect()
});
window.set_releases(model(releases));
let artists = library.map_or_else(Vec::new, |library| {
library.artists.iter().map(artist_to_view).collect()
});
window.set_artists(model(artists));
let selected_artist = match &state.frontend.screen {
Screen::Artist(key) => state
.backend
.search
.results
.artists
.iter()
.chain(
library
.into_iter()
.flat_map(|library| library.artists.iter()),
)
.find(|artist| &artist.key == key),
_ => None,
};
let artist_releases = selected_artist.map_or_else(Vec::new, |artist| {
let releases = library
.into_iter()
.flat_map(|library| library.featured_releases.iter())
.chain(state.backend.search.results.releases.iter())
.filter(|release| {
release
.artists
.iter()
.any(|candidate| candidate.key == artist.key)
})
.cloned();
let mut releases = merge_artist_releases(releases);
sort_releases_newest_first(&mut releases);
releases
});
window.set_artist_albums(model(
artist_releases
.iter()
.filter(|release| release.is_album())
.map(release_to_view)
.collect(),
));
window.set_artist_other_releases(model(
artist_releases
.iter()
.filter(|release| !release.is_album())
.map(release_to_view)
.collect(),
));
window.set_artist_featured_tracks(model(selected_artist.map_or_else(Vec::new, |artist| {
let tracks = library
.into_iter()
.flat_map(|library| {
library
.featured_releases
.iter()
.flat_map(|release| release.tracks.iter())
})
.chain(state.backend.search.results.tracks.iter())
.filter(|track| {
track
.featured_artists
.iter()
.any(|candidate| candidate.key == artist.key)
})
.cloned()
.collect::<Vec<_>>();
tracks_to_views(&tracks, state)
})));
render_catalog_detail(window, state, selected_artist);
let recent = library.map_or_else(Vec::new, |library| {
tracks_to_views(&library.recently_played, state)
});
window.set_tracks(model(recent));
let playlist = selected_playlist(state);
window.set_playlist_title(
playlist
.map_or_else(String::new, |playlist| playlist.title.clone())
.into(),
);
window.set_playlist_tracks(model(playlist.map_or_else(Vec::new, |playlist| {
tracks_to_views(&playlist.tracks, state)
})));
}
fn render_catalog_detail(window: &AppWindow, state: &AppState, selected_artist: Option<&Artist>) {
let selected_release = selected_release(state);
let selected_release = selected_release.as_ref();
let detail_artwork = selected_artist
.and_then(|artist| artist.artwork.uri.as_deref())
.or_else(|| selected_release.and_then(|release| release.artwork.uri.as_deref()));
let (detail_artwork, detail_has_artwork) = load_artwork(detail_artwork);
window.set_detail_artwork(detail_artwork);
window.set_detail_has_artwork(detail_has_artwork);
window.set_detail_title(
selected_artist
.map(|artist| artist.name.as_str())
.or_else(|| selected_release.map(|release| release.title.as_str()))
.unwrap_or_default()
.into(),
);
let preferred_artist = match &state.frontend.screen {
Screen::Release(_, preferred) => preferred.as_ref(),
_ => None,
};
window.set_detail_subtitle(
selected_artist
.map(|artist| {
format!(
"{} releases · {} tracks",
artist.release_count, artist.track_count
)
})
.unwrap_or_default()
.into(),
);
window.set_detail_release_type(
selected_release
.map(|release| release_type_label(&release.release_type))
.unwrap_or_default()
.into(),
);
let (main_artists, contributors) = selected_release.map_or_else(
|| (Vec::new(), Vec::new()),
|release| release_artist_credits(release, preferred_artist),
);
window.set_detail_main_artists(model(
main_artists
.iter()
.enumerate()
.map(|(index, artist)| artist_link_view(artist, index))
.collect(),
));
window.set_detail_contributor_lines(model(contributor_lines(&contributors, 420.0)));
window.set_detail_tracks(model(selected_release.map_or_else(Vec::new, |release| {
release_tracks_to_views(&release.tracks, state)
})));
window.set_detail_federation_state(selected_release.map_or(0, release_federation_state));
}
pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
let queue = state
.backend
.queue
.items()
.iter()
.enumerate()
.map(|(index, item)| {
let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref());
QueueView {
key: item.id.get().to_string().into(),
title: item.track.title.clone().into(),
artist: item.track.artist.clone().into(),
artist_key: item
.track
.artists
.first()
.map(|artist| format_artist_key(&artist.key))
.unwrap_or_default()
.into(),
release: item.track.release.clone().into(),
release_key: format_release_key(&item.track.release_id).into(),
active: state.backend.queue.current_index() == Some(index),
artwork,
has_artwork,
}
})
.collect();
window.set_queue_items(model(queue));
}
pub(super) fn render_playback(window: &AppWindow, state: &AppState) {
sync_media_session(state);
let player = state.player_projection();
window.set_has_track(player.has_track);
window.set_playing(player.playing);
window.set_elapsed(player.elapsed.into());
window.set_duration(player.duration.into());
window.set_progress(player.progress);
window.set_volume(player.volume);
window.set_shuffle_enabled(player.shuffle);
window.set_repeat_mode(match player.repeat {
PlaybackRepeat::Off => 0,
PlaybackRepeat::One => 1,
PlaybackRepeat::All => 2,
});
}
pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
let current = state.backend.queue.current().map(|item| &item.track);
window.set_current_title(
current
.map(|track| track.title.clone())
.unwrap_or_default()
.into(),
);
window.set_current_artists(model(current.map_or_else(Vec::new, track_artist_links)));
window.set_current_metadata(current.map(track_metadata_label).unwrap_or_default().into());
window.set_current_release(
current
.map(|track| track.release.clone())
.unwrap_or_default()
.into(),
);
window.set_current_release_key(
current
.map(|track| format_release_key(&track.release_id))
.unwrap_or_default()
.into(),
);
let (current_artwork, current_has_artwork) =
load_artwork(current.and_then(|track| track.cover_uri.as_deref()));
window.set_current_artwork(current_artwork);
window.set_current_has_artwork(current_has_artwork);
}
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(),
));
window.set_search_releases(model(
search
.results
.releases
.iter()
.map(release_to_view)
.collect(),
));
window.set_search_results(model(tracks_to_views(&search.results.tracks, state)));
}
fn search_duration_label(milliseconds: u64) -> String {
if milliseconds < 1_000 {
format!("{milliseconds} ms")
} else {
format!(
"{}.{:01} s",
milliseconds / 1_000,
milliseconds % 1_000 / 100
)
}
}
fn tracks_to_views(tracks: &[Track], state: &AppState) -> Vec<TrackView> {
tracks_to_views_with_numbers(tracks, state, false)
}
fn release_tracks_to_views(tracks: &[Track], state: &AppState) -> Vec<TrackView> {
tracks_to_views_with_numbers(tracks, state, true)
}
fn tracks_to_views_with_numbers(
tracks: &[Track],
state: &AppState,
use_metadata_number: bool,
) -> Vec<TrackView> {
let current = state.backend.queue.current().map(|item| &item.track.key);
tracks
.iter()
.enumerate()
.map(|(index, track)| {
let (artwork, has_artwork) = load_artwork(track.cover_uri.as_deref());
let artist_links = track_artist_links(track);
TrackView {
key: format_track_key(&track.key).into(),
number: track_number_label(track, index, use_metadata_number).into(),
title: track.title.clone().into(),
artists: model(artist_links),
artists_label: track_artist_label(track).into(),
artist_line_break: track_artist_line_break(track),
release: track.release.clone().into(),
release_key: format_release_key(&track.release_id).into(),
duration: duration_label(track.duration_seconds).into(),
active: current.is_some_and(|key| key.matches(&track.key)),
artwork,
has_artwork,
federated: matches!(track.audio_source, AudioSource::Federation { .. }),
liked: track.liked,
}
})
.collect()
}
fn track_number_label(track: &Track, index: usize, use_metadata_number: bool) -> String {
if use_metadata_number {
track
.track_number
.map_or_else(|| "—".into(), |number| number.to_string())
} else {
(index + 1).to_string()
}
}
fn track_artist_links(track: &Track) -> Vec<ArtistLinkView> {
let mut links = Vec::with_capacity(track.artists.len() + track.featured_artists.len());
for (index, artist) in track.artists.iter().enumerate() {
links.push(ArtistLinkView {
key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(),
prefix: if index == 0 { "" } else { ", " }.into(),
});
}
for (index, artist) in track.featured_artists.iter().enumerate() {
links.push(ArtistLinkView {
key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(),
prefix: if index == 0 { " feat. " } else { ", " }.into(),
});
}
links
}
fn track_artist_label(track: &Track) -> String {
let mut label = track
.artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
if !track.featured_artists.is_empty() {
if !label.is_empty() {
label.push_str(" feat. ");
}
label.push_str(
&track
.featured_artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", "),
);
}
if label.is_empty() {
track.artist.clone()
} else {
label
}
}
fn track_artist_line_break(track: &Track) -> i32 {
let segments = track
.artists
.iter()
.map(|artist| artist.name.chars().count() + 2)
.chain(
track
.featured_artists
.iter()
.map(|artist| artist.name.chars().count() + 2),
)
.collect::<Vec<_>>();
if segments.len() < 2 {
return 0;
}
let total = segments.iter().sum::<usize>();
let mut prefix = 0_usize;
let mut best_index = 1_usize;
let mut best_delta = usize::MAX;
for (index, width) in segments.iter().take(segments.len() - 1).enumerate() {
prefix = prefix.saturating_add(*width);
let delta = prefix.abs_diff(total.saturating_sub(prefix));
if delta < best_delta {
best_delta = delta;
best_index = index + 1;
}
}
i32::try_from(best_index).unwrap_or(i32::MAX)
}
fn track_metadata_label(track: &Track) -> String {
let mut parts = Vec::with_capacity(2);
if let Some(format) = track
.audio_format
.as_deref()
.filter(|value| !value.is_empty())
{
parts.push(format.to_uppercase());
}
if let Some(bitrate) = track.audio_bitrate_kbps {
parts.push(format!("{bitrate} kbps"));
}
parts.join(" · ")
}
fn artist_link_view(artist: &ArtistRef, index: usize) -> ArtistLinkView {
ArtistLinkView {
key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(),
prefix: if index == 0 { "" } else { ", " }.into(),
}
}
pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec<ArtistLinkLineView> {
let width = width.max(220.0);
let mut lines = Vec::<Vec<ArtistLinkView>>::new();
let mut current = Vec::new();
let mut current_width = 0.0_f32;
for (index, artist) in artists.iter().enumerate() {
// Eleven-pixel system text averages a little under seven logical
// pixels per glyph. The conservative estimate keeps every artist on
// one line while still packing short names naturally.
let has_trailing_comma = index + 1 < artists.len();
let glyph_count = u16::try_from(artist.name.chars().count()).unwrap_or(u16::MAX);
let name_width = f32::from(glyph_count) * 7.0 + if has_trailing_comma { 3.0 } else { 0.0 };
let separator_width = if current.is_empty() { 0.0 } else { 9.0 };
if !current.is_empty() && current_width + separator_width + name_width > width {
lines.push(std::mem::take(&mut current));
current_width = 0.0;
}
let prefix = if current.is_empty() { "" } else { " " };
current.push(ArtistLinkView {
key: format_artist_key(&artist.key).into(),
name: if has_trailing_comma {
format!("{},", artist.name)
} else {
artist.name.clone()
}
.into(),
prefix: prefix.into(),
});
current_width += (if prefix.is_empty() { 0.0 } else { 9.0 }) + name_width;
if index + 1 == artists.len() && !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
}
lines
.into_iter()
.map(|artists| ArtistLinkLineView {
artists: model(artists),
})
.collect()
}
fn artist_to_view(artist: &Artist) -> ArtistView {
let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref());
ArtistView {
key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(),
details: format!(
"{} releases · {} tracks",
artist.release_count, artist.track_count
)
.into(),
artwork,
has_artwork,
federated: matches!(artist.source, CatalogSource::Federation { .. }),
}
}
fn release_to_view(release: &Release) -> ReleaseView {
let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref());
ReleaseView {
key: format_release_key(&release.key).into(),
title: release.title.clone().into(),
artist: release.artist_line().into(),
artist_key: release
.artists
.first()
.map(|artist| format_artist_key(&artist.key))
.unwrap_or_default()
.into(),
year: release
.year
.map_or_else(String::new, |year| year.to_string())
.into(),
release_type: release_type_label(&release.release_type).into(),
artwork,
has_artwork,
federation_state: release_federation_state(release),
}
}
fn release_federation_state(release: &Release) -> i32 {
if release.tracks.is_empty() {
return i32::from(matches!(release.source, CatalogSource::Federation { .. }));
}
let local = release
.tracks
.iter()
.filter(|track| matches!(track.audio_source, AudioSource::LocalFile(_)))
.count();
match local {
0 => 1,
count if count == release.tracks.len() => 0,
_ => 2,
}
}
fn release_type_label(release_type: &str) -> String {
let release_type = release_type.trim();
if release_type.is_empty() {
return "Release".into();
}
if release_type.eq_ignore_ascii_case("ep") {
return "EP".into();
}
let mut characters = release_type.chars();
let Some(first) = characters.next() else {
return "Release".into();
};
first.to_uppercase().chain(characters).collect()
}
pub(super) fn release_artist_credits(
release: &Release,
preferred: Option<&ArtistKey>,
) -> (Vec<ArtistRef>, Vec<ArtistRef>) {
let all_artists = || {
release
.artists
.iter()
.chain(release.featured_artists.iter())
.chain(
release
.tracks
.iter()
.flat_map(|track| track.artists.iter().chain(track.featured_artists.iter())),
)
};
let primary = preferred
.and_then(|preferred| all_artists().find(|artist| &artist.key == preferred))
.or_else(|| release.artists.first())
.or_else(|| {
release
.tracks
.iter()
.find_map(|track| track.artists.first())
})
.cloned();
let mut known = HashSet::new();
let mut main = Vec::new();
if let Some(primary) = primary {
known.insert(artist_name_key(&primary.name));
main.push(primary);
}
let contributors = all_artists()
.filter(|artist| known.insert(artist_name_key(&artist.name)))
.cloned()
.collect();
(main, contributors)
}
fn artist_name_key(name: &str) -> String {
name.trim().to_lowercase()
}
fn merge_artist_releases(releases: impl IntoIterator<Item = Release>) -> Vec<Release> {
let mut merged = Vec::<Release>::new();
let mut slots = HashMap::<(String, String, Option<i32>), usize>::new();
for release in releases {
let identity = (
release.title.trim().to_lowercase(),
release.release_type.trim().to_lowercase(),
release.year,
);
if let Some(index) = slots.get(&identity).copied() {
merge_release_availability(&mut merged[index], release);
} else {
slots.insert(identity, merged.len());
merged.push(release);
}
}
merged
}
fn sort_releases_newest_first(releases: &mut [Release]) {
releases.sort_by(|left, right| {
right
.year
.unwrap_or(i32::MIN)
.cmp(&left.year.unwrap_or(i32::MIN))
.then_with(|| left.release_type.cmp(&right.release_type))
.then_with(|| left.title.cmp(&right.title))
});
}
fn merge_release_availability(target: &mut Release, incoming: Release) {
if matches!(target.source, CatalogSource::Federation { .. })
&& matches!(incoming.source, CatalogSource::Local)
{
let previous = std::mem::replace(target, incoming);
merge_release_availability(target, previous);
return;
}
if target.artwork.uri.is_none() && incoming.artwork.uri.is_some() {
target.artwork = incoming.artwork;
}
merge_credit_refs(&mut target.artists, incoming.artists);
merge_credit_refs(&mut target.featured_artists, incoming.featured_artists);
for track in incoming.tracks {
if let Some(existing) = target
.tracks
.iter_mut()
.find(|existing| tracks_match_within_release(existing, &track))
{
if matches!(existing.audio_source, AudioSource::Federation { .. })
&& matches!(track.audio_source, AudioSource::LocalFile(_))
{
*existing = track;
}
} else {
target.tracks.push(track);
}
}
sort_release_tracks(&mut target.tracks);
}
fn sort_release_tracks(tracks: &mut [Track]) {
tracks.sort_by(|left, right| {
left.disc_number
.unwrap_or(1)
.cmp(&right.disc_number.unwrap_or(1))
.then_with(|| {
left.track_number
.unwrap_or(u32::MAX)
.cmp(&right.track_number.unwrap_or(u32::MAX))
})
.then_with(|| left.title.cmp(&right.title))
});
}
fn tracks_match_within_release(left: &Track, right: &Track) -> bool {
if left.key.matches(&right.key) {
return true;
}
if let (Some(left_number), Some(right_number)) = (left.track_number, right.track_number)
&& left_number == right_number
&& left.disc_number.unwrap_or(1) == right.disc_number.unwrap_or(1)
{
return true;
}
left.title.trim().eq_ignore_ascii_case(right.title.trim())
}
fn merge_credit_refs(target: &mut Vec<ArtistRef>, incoming: Vec<ArtistRef>) {
let mut known = target
.iter()
.map(|artist| artist_name_key(&artist.name))
.collect::<HashSet<_>>();
target.extend(
incoming
.into_iter()
.filter(|artist| known.insert(artist_name_key(&artist.name))),
);
}
fn load_artwork(uri: Option<&str>) -> (Image, bool) {
let Some(uri) = uri.filter(|uri| !uri.trim().is_empty()) else {
return (Image::default(), false);
};
let path = PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri));
let cached = ARTWORK_CACHE.with(|cache| cache.borrow().get(&path).cloned());
let image = cached.unwrap_or_else(|| {
let image = decode_artwork(&path).ok();
ARTWORK_CACHE.with(|cache| {
cache.borrow_mut().insert(path.clone(), image.clone());
});
image
});
image.map_or_else(|| (Image::default(), false), |image| (image, true))
}
fn decode_artwork(path: &Path) -> Result<Image, String> {
if path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("webp"))
{
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
decode_webp(&bytes)
} else {
Image::load_from_path(path).map_err(|error| error.to_string())
}
}
fn decode_webp(bytes: &[u8]) -> Result<Image, String> {
let rgba = image::load_from_memory_with_format(bytes, image::ImageFormat::WebP)
.map_err(|error| error.to_string())?
.into_rgba8();
let buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
rgba.as_raw(),
rgba.width(),
rgba.height(),
);
Ok(Image::from_rgba8(buffer))
}
fn format_artist_key(key: &ArtistKey) -> String {
match key {
ArtistKey::Local(id) => format!("local-artist:{}", id.get()),
ArtistKey::Federation { peer_id, id } => format!("fed-artist:{peer_id}:{id}"),
}
}
fn format_release_key(key: &ReleaseKey) -> String {
match key {
ReleaseKey::Local(id) => format!("local-release:{}", id.get()),
ReleaseKey::Federation { peer_id, id } => format!("fed-release:{peer_id}:{id}"),
}
}
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 find_release_key(state: &AppState, value: &SharedString) -> Option<ReleaseKey> {
let local = match &state.backend.library {
RemoteData::Ready(library) => library.featured_releases.as_slice(),
_ => &[],
};
local
.iter()
.chain(state.backend.search.results.releases.iter())
.find(|release| format_release_key(&release.key) == value.as_str())
.map(|release| release.key.clone())
}
fn format_track_key(key: &TrackKey) -> String {
if let Some(local) = key.local_id() {
format!("local:{}", local.get())
} else if let Some((peer, item)) = key.federation_id() {
format!(
"fed-track:{peer}:{item}:{}",
key.content_id().map_or("", ContentId::as_str)
)
} else if let Some(content) = key.content_id() {
content.as_str().to_owned()
} else {
String::new()
}
}
pub(super) fn parse_track_key(value: &SharedString) -> Option<TrackKey> {
if let Some(local) = value.strip_prefix("local:") {
return local
.parse::<i64>()
.ok()
.map(|id| TrackKey::local(LocalTrackId::new(id)));
}
if let Some(federated) = value.strip_prefix("fed-track:") {
let mut parts = federated.splitn(3, ':');
let peer = parts.next()?.to_owned();
let item = parts.next()?.to_owned();
let content = parts
.next()
.filter(|value| !value.is_empty())
.and_then(|value| ContentId::parse(value).ok());
return Some(TrackKey::federation(peer, item, content));
}
furumi_domain::ContentId::parse(value.to_string())
.ok()
.map(TrackKey::remote)
}
pub(super) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
ModelRc::from(Rc::new(VecModel::from(items)))
}
#[cfg(test)]
mod tests {
use image::ImageEncoder as _;
use super::*;
#[test]
fn webp_is_converted_to_a_slint_image() {
let mut encoded = Vec::new();
image::codecs::webp::WebPEncoder::new_lossless(&mut encoded)
.write_image(&[20, 40, 60, 255], 1, 1, image::ExtendedColorType::Rgba8)
.unwrap();
let decoded = decode_webp(&encoded).unwrap();
assert_eq!(decoded.size().width, 1);
assert_eq!(decoded.size().height, 1);
}
#[test]
fn release_credits_include_artists_found_only_on_tracks() {
let pasha = ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(1)),
name: "Паша Техник".into(),
};
let kunteynir = ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(2)),
name: "KUNTEYNIR".into(),
};
let metox = ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(3)),
name: "Metox".into(),
};
let release = Release {
key: ReleaseKey::local(furumi_domain::ReleaseId::new(7)),
source: CatalogSource::Local,
title: "Порядочный".into(),
artists: vec![pasha.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: None,
artwork: furumi_domain::Artwork::default(),
tracks: vec![Track {
key: TrackKey::local(LocalTrackId::new(10)),
title: "12 шагов до рая".into(),
artist: "KUNTEYNIR feat. Metox".into(),
artists: vec![kunteynir],
featured_artists: vec![metox],
release: "Порядочный".into(),
release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(7)),
duration_seconds: 219.0,
track_number: Some(1),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("mp3".into()),
audio_bitrate_kbps: Some(320),
audio_sample_rate_hz: Some(44_100),
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from("track.mp3")),
}],
};
let (main, contributors) = release_artist_credits(&release, Some(&pasha.key));
assert_eq!(
main.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>(),
vec!["Паша Техник"]
);
assert_eq!(
contributors
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>(),
vec!["KUNTEYNIR", "Metox"]
);
}
#[test]
fn artist_release_tile_reports_mixed_local_and_federated_tracks() {
let artist = ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(1)),
name: "Artist".into(),
};
let local = Release {
key: ReleaseKey::local(furumi_domain::ReleaseId::new(7)),
source: CatalogSource::Local,
title: "Album".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: furumi_domain::Artwork::default(),
tracks: vec![Track {
key: TrackKey::local(LocalTrackId::new(10)),
title: "Local".into(),
artist: "Artist".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release: "Album".into(),
release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(7)),
duration_seconds: 100.0,
track_number: Some(1),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("flac".into()),
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from("local.flac")),
}],
};
let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let mut remote_track = local.tracks[0].clone();
remote_track.key = TrackKey::federation(
"peer".into(),
"remote-track".into(),
Some(content_id.clone()),
);
remote_track.title = "Remote".into();
remote_track.track_number = Some(2);
remote_track.audio_source = AudioSource::Federation {
peer_id: "peer".into(),
content_id,
};
let remote = Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: "album".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Album".into(),
artists: vec![artist],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: furumi_domain::Artwork::default(),
tracks: vec![remote_track],
};
let merged = merge_artist_releases([local, remote]);
assert_eq!(merged.len(), 1);
assert!(matches!(merged[0].key, ReleaseKey::Local(_)));
assert_eq!(release_federation_state(&merged[0]), 2);
}
#[test]
fn artist_releases_are_sorted_newest_first_with_unknown_years_last() {
let artist = ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(1)),
name: "Artist".into(),
};
let release = |title: &str, year| Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: title.into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: title.into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "single".into(),
year,
artwork: furumi_domain::Artwork::default(),
tracks: Vec::new(),
};
let mut releases = vec![
release("Old", Some(1999)),
release("Unknown", None),
release("New", Some(2026)),
];
sort_releases_newest_first(&mut releases);
assert_eq!(
releases
.iter()
.map(|release| release.title.as_str())
.collect::<Vec<_>>(),
vec!["New", "Old", "Unknown"]
);
}
#[test]
fn release_rows_use_metadata_track_numbers_instead_of_visual_positions() {
let mut track = Track {
key: TrackKey::local(LocalTrackId::new(1)),
title: "Track".into(),
artist: "Artist".into(),
artists: Vec::new(),
featured_artists: Vec::new(),
release: "Release".into(),
release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(1)),
duration_seconds: 1.0,
track_number: Some(9),
disc_number: Some(1),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from("track.flac")),
};
assert_eq!(track_number_label(&track, 0, true), "9");
assert_eq!(track_number_label(&track, 0, false), "1");
track.track_number = None;
assert_eq!(track_number_label(&track, 8, true), "—");
}
#[test]
fn full_artist_label_keeps_main_and_featured_artists() {
let mut track = Track {
key: TrackKey::local(LocalTrackId::new(1)),
title: "Track".into(),
artist: String::new(),
artists: vec![ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(1)),
name: "Main".into(),
}],
featured_artists: vec![
ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(2)),
name: "Guest A".into(),
},
ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(3)),
name: "Guest B".into(),
},
],
release: "Release".into(),
release_id: ReleaseKey::local(furumi_domain::ReleaseId::new(1)),
duration_seconds: 1.0,
track_number: None,
disc_number: None,
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from("track.flac")),
};
assert_eq!(track_artist_label(&track), "Main feat. Guest A, Guest B");
track.artists.clear();
assert_eq!(track_artist_label(&track), "Guest A, Guest B");
}
#[test]
fn contributors_wrap_into_compact_comma_separated_lines() {
use slint::Model as _;
let artists = ["Alpha Alpha", "Beta Beta", "Gamma Gamma"]
.into_iter()
.zip(1_i64..)
.map(|(name, id)| ArtistRef {
key: ArtistKey::local(furumi_domain::ArtistId::new(id)),
name: name.into(),
})
.collect::<Vec<_>>();
let lines = contributor_lines(&artists, 80.0);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].artists.row_count(), 2);
assert_eq!(lines[1].artists.row_count(), 1);
assert_eq!(lines[0].artists.row_data(0).unwrap().prefix, "");
assert_eq!(lines[0].artists.row_data(0).unwrap().name, "Alpha Alpha,");
assert_eq!(lines[0].artists.row_data(1).unwrap().prefix, " ");
assert_eq!(lines[1].artists.row_data(0).unwrap().prefix, "");
}
}