Fixed UI bugs

This commit is contained in:
Aleksandr Bogomiakov
2026-08-14 02:14:46 +01:00
parent 2dc40c2c3f
commit 0d7c9b9f5e
24 changed files with 4351 additions and 158 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 3-6.7"/>
<path d="M3 4.5v5h5"/>
<path d="M12 7.5V12l3 2"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+162 -3
View File
@@ -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
View File
@@ -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 {
+365 -46
View File
@@ -1,6 +1,37 @@
import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint";
import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint";
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint";
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, RecommendedButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackActionButton, TrackInfoArtists, TrackList, TrackMenuItem } from "components.slint";
component FederationStateIndicator inherits Rectangle {
in property <bool> busy;
in property <bool> running;
width: 12px;
height: 100%;
horizontal-stretch: 0;
background: transparent;
if !root.busy: Rectangle {
width: 9px;
height: 9px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
background: root.running ? #55dbaa : #697080;
}
if root.busy: pulse := Rectangle {
private property <int> phase: floor(1 * mod(animation-tick(), 2s) / 250ms);
private property <int> level: self.phase < 4 ? self.phase : 7 - self.phase;
width: 12px - self.level * 2px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 2px;
border-color: #55dbaa;
background: transparent;
}
}
export component AppWindow inherits Window {
title: "Furumi Desktop";
@@ -17,7 +48,7 @@ export component AppWindow inherits Window {
in property <string> search-label: "Search";
in property <string> library-label: "Your library";
in property <string> queue-label: "Queue";
in property <string> recent-label: "Recently played";
in property <string> history-label: "Listening history";
in property <string> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query;
@@ -29,6 +60,19 @@ export component AppWindow inherits Window {
in property <string> library-path;
in property <bool> federation-enabled;
in property <bool> save-federated-on-listen;
in property <bool> similarity-enabled;
in property <string> similarity-model;
in property <[string]> similarity-models;
in property <string> similarity-profile;
in property <[string]> similarity-profiles;
in property <int> similarity-workers: 1;
in property <float> similarity-minimum-score: 0.7;
in property <int> similarity-max-tracks-per-artist: 5;
in property <bool> similarity-federation-consent;
in property <string> similarity-status-phase: "disabled";
in property <string> similarity-status-progress: "0 / 0";
in property <string> similarity-status-storage: "0 vectors";
in property <string> similarity-status-current;
in property <string> selected-language: "English";
in property <[string]> available-languages;
in property <[ReleaseView]> releases;
@@ -44,8 +88,15 @@ export component AppWindow inherits Window {
in property <string> detail-release-type;
in property <image> detail-artwork;
in property <bool> detail-has-artwork;
in property <[TrackView]> tracks;
in property <[TrackView]> history-tracks;
in property <[TrackView]> similarity-tracks;
in property <string> similarity-source-title;
in property <bool> similarity-pending;
in property <string> similarity-error;
in property <[QueueView]> queue-items;
private property <int> queue-drag-source: -1;
private property <int> queue-drag-target: -1;
private property <length> queue-drag-offset: 0px;
in property <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks;
in property <string> playlist-title;
@@ -63,6 +114,7 @@ export component AppWindow inherits Window {
in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy;
in property <bool> federation-status-running;
in property <string> federation-status-text: "Federation ready";
in property <string> federation-debug-node: "Stopped";
in property <string> federation-debug-peers: "0 connected · 0 known";
@@ -125,6 +177,8 @@ export component AppWindow inherits Window {
callback play-track(string);
callback play-track-context(string, string);
callback play-queue-item(string);
callback move-queue-item(string, int);
callback remove-queue-item(string);
callback track-action(string, string);
callback open-playlist(string);
callback create-playlist(string);
@@ -141,6 +195,14 @@ export component AppWindow inherits Window {
callback choose-library-path;
callback federation-changed(bool);
callback save-federated-on-listen-changed(bool);
callback similarity-enabled-changed(bool);
callback similarity-model-changed(string);
callback similarity-profile-changed(string);
callback similarity-workers-changed(int);
callback similarity-minimum-score-changed(float);
callback similarity-max-tracks-per-artist-changed(int);
callback similarity-federation-consent-changed(bool);
callback clear-similarity;
callback language-changed(string);
callback dismiss-error;
callback close-track-info;
@@ -171,6 +233,7 @@ export component AppWindow inherits Window {
NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); }
NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); }
NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); }
NavButton { label: root.history-label; icon-source: @image-url("../assets/history.svg"); active: root.active-screen == "history"; clicked => root.navigate("history"); }
Rectangle { height: 12px; background: transparent; }
HorizontalLayout {
height: 28px;
@@ -197,14 +260,6 @@ export component AppWindow inherits Window {
}
}
NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); }
Rectangle {
height: 58px; border-radius: 9px; background: #171a22;
HorizontalLayout {
padding: 10px; spacing: 10px;
Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } }
VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } }
}
}
}
new-playlist-popup := PopupWindow {
@@ -269,7 +324,7 @@ export component AppWindow inherits Window {
border-width: 1px;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 6px;
Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; }
FederationStateIndicator { busy: root.federation-status-busy; running: root.federation-status-running; }
status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
@@ -285,9 +340,18 @@ export component AppWindow inherits Window {
Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; }
Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; }
ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); }
Rectangle { height: 8px; background: transparent; }
Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; }
TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "history": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: root.history-label; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; }
Text { height: 18px; vertical-stretch: 0; text: root.history-tracks.length + (root.history-tracks.length == 1 ? " listen" : " listens"); color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.history-tracks; show-artwork: true; play(key) => root.play-track-context("history", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
@@ -302,6 +366,20 @@ export component AppWindow inherits Window {
}
}
if root.active-screen == "similarity": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: "Similar to “" + root.similarity-source-title + "”"; color: #f5f6fa; font-size: 30px; font-weight: 800; overflow: elide; vertical-alignment: center; }
if root.similarity-pending: Text { height: 22px; text: "Searching local index and federation…"; color: #55dbaa; font-size: 12px; vertical-alignment: center; }
if root.similarity-error != "": Text { height: 40px; text: root.similarity-error; color: #ff8d98; font-size: 12px; wrap: word-wrap; }
if !root.similarity-pending && root.similarity-error == "" && root.similarity-tracks.length == 0: Text { height: 28px; text: "No matching tracks found."; color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.similarity-tracks; show-artwork: true; play(key) => root.play-track-context("similarity", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
}
}
if root.active-screen == "playlist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
@@ -405,7 +483,7 @@ export component AppWindow inherits Window {
}
if root.queue-open: queue-panel := Rectangle {
width: 292px;
width: 370px;
background: #11141b;
border-color: #252936;
border-width: 1px;
@@ -425,24 +503,132 @@ export component AppWindow inherits Window {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
mouse-drag-pan-enabled: root.queue-drag-source < 0;
queue-list := Rectangle {
width: parent.width;
spacing: 4px;
alignment: start;
for item in root.queue-items: Rectangle {
height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent;
TouchArea { double-clicked => root.play-queue-item(item.key); }
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
width: 190px; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
height: root.queue-items.length > 0 ? root.queue-items.length * 56px - 4px : 0px;
background: transparent;
for item[index] in root.queue-items: queue-row := Rectangle {
private property <int> visual-index: root.queue-drag-source < 0 ? index
: index == root.queue-drag-source ? index
: root.queue-drag-source < root.queue-drag-target && index > root.queue-drag-source && index <= root.queue-drag-target ? index - 1
: root.queue-drag-target < root.queue-drag-source && index >= root.queue-drag-target && index < root.queue-drag-source ? index + 1
: index;
x: 0px;
y: self.visual-index * 56px;
width: parent.width;
height: 52px;
background: transparent;
animate y { duration: 110ms; easing: ease-out; }
queue-drag := TouchArea {
private property <length> start-y;
mouse-cursor: self.pressed ? grabbing : grab;
pointer-event(event) => {
if event.kind == PointerEventKind.cancel {
if root.queue-drag-source == index {
root.queue-drag-source = -1;
root.queue-drag-target = -1;
root.queue-drag-offset = 0px;
}
return;
}
if event.button != PointerEventButton.left {
return;
}
if event.kind == PointerEventKind.down {
self.start-y = self.mouse-y;
root.queue-drag-source = index;
root.queue-drag-target = index;
root.queue-drag-offset = 0px;
} else if event.kind == PointerEventKind.up {
if root.queue-drag-source == index && root.queue-drag-target != index {
root.move-queue-item(item.key, root.queue-drag-target);
}
root.queue-drag-source = -1;
root.queue-drag-target = -1;
root.queue-drag-offset = 0px;
}
}
moved => {
if self.pressed && root.queue-drag-source == index {
root.queue-drag-offset = self.mouse-y - self.start-y;
root.queue-drag-target = max(0, min(root.queue-items.length - 1, index + round(root.queue-drag-offset / 56px)));
}
}
double-clicked => root.play-queue-item(item.key);
}
row-visual := Rectangle {
width: parent.width;
height: parent.height;
opacity: root.queue-drag-source == index ? 0 : 1;
border-radius: 7px;
background: item.active ? #242a35 : queue-drag.has-hover ? #1b1f29 : transparent;
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
}
}
TrackActionButton { active: item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.track-action("like", item.track-key); }
TrackActionButton { icon-source: @image-url("../assets/more.svg"); clicked => queue-menu.show(); }
IconButton { width: 28px; height: 28px; icon-source: @image-url("../assets/close.svg"); clicked => root.remove-queue-item(item.key); }
}
}
queue-menu := PopupWindow {
x: queue-row.width - self.width - 8px;
y: 46px;
width: 184px;
height: 118px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 8px;
background: #222631;
border-color: #3a3f4d;
border-width: 1px;
drop-shadow-color: #00000080;
drop-shadow-blur: 14px;
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: 6px; spacing: 1px;
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { queue-menu.close(); root.track-action("information", item.track-key); } }
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { queue-menu.close(); root.track-action("similar", item.track-key); } }
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { queue-menu.close(); root.track-action("playlist", item.track-key); } }
}
}
}
}
if root.queue-drag-source >= 0: drag-preview := Rectangle {
private property <QueueView> item: root.queue-items[root.queue-drag-source];
x: 0px;
y: max(0px, min(parent.height - self.height, root.queue-drag-source * 56px + root.queue-drag-offset));
width: parent.width;
height: 52px;
border-radius: 7px;
background: #29362f;
border-width: 1px;
border-color: #55dbaa;
drop-shadow-color: #000000a0;
drop-shadow-blur: 16px;
drop-shadow-offset-y: 5px;
HorizontalLayout {
padding: 7px; spacing: 10px;
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if drag-preview.item.has-artwork: Image { source: drag-preview.item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
Rectangle {
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
VerticalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
MarqueeText { width: parent.width; height: 18px; label: drag-preview.item.title; text-color: drag-preview.item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
Text { width: parent.width; height: 15px; text: drag-preview.item.artist; color: #777d8d; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
Rectangle { width: 28px; height: 28px; border-radius: 6px; background: drag-preview.item.liked ? #255845 : transparent; Image { source: @image-url("../assets/heart.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/more.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/close.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
}
}
}
@@ -565,6 +751,28 @@ export component AppWindow inherits Window {
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for pairing in root.pending-pairings: Rectangle {
height: pairing.group-conflict ? 142px : 76px; border-radius: 7px; background: #252a35;
VerticalLayout {
padding: 8px; spacing: 5px;
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
if pairing.group-conflict: Text { text: pairing.group-summary; color: #d9a9d0; font-size: 9px; overflow: elide; }
if pairing.group-conflict: Text { text: "Join theirs to keep its current peers syncing."; color: #9ca4b4; font-size: 9px; }
if pairing.group-conflict: Text { text: "Keep this group moves only this device; its old peers stop syncing."; color: #9ca4b4; font-size: 9px; }
if pairing.group-conflict: HorizontalLayout {
spacing: 7px;
RecommendedButton { text: "Join their group"; clicked => root.answer-pairing(pairing.request-id, true, true); }
Button { text: "Keep this group"; clicked => root.answer-pairing(pairing.request-id, true, false); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
if !pairing.group-conflict: HorizontalLayout {
spacing: 7px;
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, false); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
}
}
for device in root.connected-devices: DeviceRow {
visible: !device.active;
height: !device.active ? 48px : 0px;
@@ -572,23 +780,31 @@ export component AppWindow inherits Window {
selectable: !device.revoked && (device.online || device.is-self);
selected(id) => root.select-device(id);
}
for pairing in root.pending-pairings: Rectangle {
height: 76px; border-radius: 7px; background: #252a35;
VerticalLayout {
padding: 8px; spacing: 5px;
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
HorizontalLayout {
spacing: 7px;
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); }
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
}
}
}
}
}
Rectangle { height: 1px; background: #303541; }
if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; }
if root.device-invite != "": HorizontalLayout {
height: 32px; spacing: 8px;
invite-code-frame := Rectangle {
horizontal-stretch: 1;
min-width: 0px;
height: 32px;
clip: true;
invite-code := LineEdit {
width: parent.width;
height: parent.height;
text: root.device-invite;
read-only: true;
}
}
Button {
text: "Copy";
clicked => {
invite-code.select-all();
invite-code.copy();
}
}
}
HorizontalLayout {
spacing: 8px;
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
@@ -705,7 +921,7 @@ export component AppWindow inherits Window {
spacing: 8px;
LineEdit {
text: root.library-path;
placeholder-text: "~/Music/Furumi";
placeholder-text: "Choose a music directory";
horizontal-stretch: 1;
edited => root.library-path-changed(self.text);
}
@@ -747,6 +963,109 @@ export component AppWindow inherits Window {
}
}
}
Rectangle { height: 1px; background: #2a2e38; }
Text { height: 22px; text: "Similarity search"; color: #f0f2f7; font-size: 15px; font-weight: 750; vertical-alignment: center; }
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Find similar tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Build a private local audio index. Downloads the selected model when enabled."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.similarity-enabled;
toggled => root.similarity-enabled-changed(self.checked);
}
}
HorizontalLayout {
height: 44px; spacing: 12px;
VerticalLayout {
horizontal-stretch: 1;
Text { text: "Embedding model"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
Text { text: "CC BY-NC-SA 4.0 (or proprietary from MTG)"; color: #818797; font-size: 10px; }
}
ComboBox {
width: 244px;
model: root.similarity-models;
current-value: root.similarity-model;
selected(value) => root.similarity-model-changed(value);
}
}
HorizontalLayout {
height: 44px; spacing: 12px;
VerticalLayout {
horizontal-stretch: 1;
Text { text: "Preprocessing profile"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
Text { text: "Full track; long tracks use balanced windows."; color: #818797; font-size: 10px; }
}
ComboBox {
width: 244px;
model: root.similarity-profiles;
current-value: root.similarity-profile;
selected(value) => root.similarity-profile-changed(value);
}
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Minimum similarity"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: round(root.similarity-minimum-score * 100) + "%"; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 0; maximum: 1; value: root.similarity-minimum-score; changed(value) => root.similarity-minimum-score-changed(value); }
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Maximum tracks per artist"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: root.similarity-max-tracks-per-artist; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 1; maximum: 50; value: root.similarity-max-tracks-per-artist; changed(value) => root.similarity-max-tracks-per-artist-changed(round(value)); }
}
VerticalLayout {
spacing: 6px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Background workers"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
Text { text: root.similarity-workers; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
}
Slider { height: 18px; minimum: 1; maximum: 16; value: root.similarity-workers; changed(value) => root.similarity-workers-changed(round(value)); }
}
HorizontalLayout {
height: 62px;
VerticalLayout {
Text { text: "Search federation too"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Send an anonymous numeric embedding to peers. No account identity is included."; color: #818797; font-size: 10px; }
Text { text: "A peer may infer the kind of music being searched."; color: #d4ad62; font-size: 10px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "I agree";
checked: root.similarity-federation-consent;
toggled => root.similarity-federation-consent-changed(self.checked);
}
}
Rectangle {
min-height: 112px; max-height: 112px; vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 5px;
HorizontalLayout {
height: 20px;
Text { horizontal-stretch: 1; text: "Similarity index"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { text: root.similarity-status-phase; color: root.similarity-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; }
}
Text { height: 17px; text: "Progress " + root.similarity-status-progress + " Stored " + root.similarity-status-storage; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { height: 17px; text: root.similarity-status-current; color: #697080; font-size: 10px; overflow: elide; vertical-alignment: center; }
HorizontalLayout {
height: 28px;
Rectangle { horizontal-stretch: 1; background: transparent; }
Button { text: "Clear stored embeddings"; clicked => root.clear-similarity(); }
}
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
+52 -4
View File
@@ -15,6 +15,49 @@ export component IconButton inherits Rectangle {
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
}
export component RecommendedButton inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
callback clicked;
min-width: label.preferred-width + 24px;
min-height: 32px;
horizontal-stretch: 0;
vertical-stretch: 0;
border-radius: 4px;
border-width: 1px;
border-color: root.enabled ? #65d7a9 : #49645a;
background: !root.enabled ? #31443d : touch.pressed ? #267657 : touch.has-hover ? #3aa77d : #318e6b;
opacity: root.enabled ? 1 : 0.55;
accessible-role: button;
accessible-enabled: root.enabled;
accessible-label: root.text;
accessible-action-default => { root.clicked(); }
forward-focus: focus;
label := Text {
text: root.text;
color: #ffffff;
font-size: 11px;
font-weight: 700;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
enabled: root.enabled;
clicked => root.clicked();
}
focus := FocusScope {
enabled: root.enabled;
key-pressed(event) => {
if event.text == " " || event.text == "\n" {
root.clicked();
return accept;
}
return reject;
}
}
}
export component DeviceRow inherits Rectangle {
in property <DeviceView> item;
in property <bool> selectable: false;
@@ -146,9 +189,14 @@ export component PlaylistNavButton inherits Rectangle {
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout {
padding-left: 10px; padding-right: 8px; spacing: 9px;
Image {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
width: 15px; height: 15px;
Rectangle {
width: 15px;
background: transparent;
Image {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
width: 15px; height: 15px;
y: (parent.height - self.height) / 2;
}
}
Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; }
}
@@ -629,7 +677,7 @@ export component TrackRow inherits Rectangle {
VerticalLayout {
padding: 6px; spacing: 1px;
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { menu.close(); root.action("similar", root.item.key); } }
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
}
}
+3
View File
@@ -55,12 +55,14 @@ export struct TrackView {
export struct QueueView {
key: string,
track-key: string,
title: string,
artist: string,
artist-key: string,
release: string,
release-key: string,
active: bool,
liked: bool,
artwork: image,
has-artwork: bool,
}
@@ -87,4 +89,5 @@ export struct PairingView {
name: string,
details: string,
group-conflict: bool,
group-summary: string,
}