Init commit

This commit is contained in:
Ultradesu
2026-08-12 01:30:57 +01:00
commit 423ea59371
67 changed files with 23280 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-ui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
build = "build.rs"
[dependencies]
furumi-application.workspace = true
furumi-backend.workspace = true
furumi-backend-api.workspace = true
furumi-domain.workspace = true
furumi-platform-desktop.workspace = true
image.workspace = true
slint.workspace = true
tokio.workspace = true
[build-dependencies]
slint-build.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10M4 10h10M4 14h7"/><path d="M17 14v7M13.5 17.5h7"/></svg>

After

Width:  |  Height:  |  Size: 228 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>

After

Width:  |  Height:  |  Size: 186 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 168 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.5" y="4" width="13" height="10" rx="1.8"/>
<path d="M6.5 18h5M9 14v4"/>
<rect x="17.2" y="8" width="4.3" height="9.5" rx="1.2"/>
<path d="M18.8 15.5h1"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0b1713" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 185 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8z"/></svg>

After

Width:  |  Height:  |  Size: 293 B

+1
View File
@@ -0,0 +1 @@
<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 11.5 12 4l9 7.5"/><path d="M5.5 10v10h13V10M9.5 20v-6h5v6"/></svg>

After

Width:  |  Height:  |  Size: 234 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5h.01"/></svg>

After

Width:  |  Height:  |  Size: 200 B

+1
View File
@@ -0,0 +1 @@
<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="M4 4h3v16H4zM10 4h3v16h-3zM16 5l3-.8L22 19l-3 .8z"/></svg>

After

Width:  |  Height:  |  Size: 223 B

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="8" y1="56" x2="56" y2="8" gradientUnits="userSpaceOnUse">
<stop stop-color="#6d5dfb"/>
<stop offset="1" stop-color="#49d7a5"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#g)"/>
<path d="M25 43V19l22-4v23" fill="none" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="19" cy="44" r="7" fill="white"/>
<circle cx="41" cy="39" r="7" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 553 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#aeb4c2"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>

After

Width:  |  Height:  |  Size: 180 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#d8dbe5"><path d="M16 5h2v14h-2zM5 5.4v13.2L15 12z"/></svg>

After

Width:  |  Height:  |  Size: 126 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="7.25" stroke="#a7adbb" stroke-width="1.5"/>
<circle cx="12" cy="12" r="2.25" fill="#a7adbb"/>
<circle cx="12" cy="12" r="0.75" fill="#292d38"/>
<path d="M8.1 8.9a5 5 0 0 1 2.1-1.45" stroke="#d3d6de" stroke-width="1.25" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#101218"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>

After

Width:  |  Height:  |  Size: 120 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h10M4 10h7M4 14h5"/><path d="m13 14 5 3-5 3z"/><path d="M20 13v8"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#101218"><path d="m8 5 11 7-11 7z"/></svg>

After

Width:  |  Height:  |  Size: 109 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h11M4 11h11M4 16h7M18 14v7M14.5 17.5h7"/></svg>

After

Width:  |  Height:  |  Size: 192 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="2" stroke-linecap="round">
<path d="M12 5v14M5 12h14"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#d8dbe5"><path d="M6 5h2v14H6zM19 5.4v13.2L9 12z"/></svg>

After

Width:  |  Height:  |  Size: 124 B

+1
View File
@@ -0,0 +1 @@
<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="M4 6h16M4 11h16M4 16h9"/><path d="m17 15 4 3-4 3z" fill="#b8bdca" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 252 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>

After

Width:  |  Height:  |  Size: 201 B

+1
View File
@@ -0,0 +1 @@
<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="M4 6h5M15 6h5M4 12h9M19 12h1M4 18h2M12 18h8"/><circle cx="12" cy="6" r="3"/><circle cx="16" cy="12" r="3"/><circle cx="9" cy="18" r="3"/></svg>

After

Width:  |  Height:  |  Size: 308 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#aeb4c2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></svg>

After

Width:  |  Height:  |  Size: 295 B

+1
View File
@@ -0,0 +1 @@
<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="M4 10v4h4l5 4V6l-5 4z"/><path d="M16 9a4 4 0 0 1 0 6M18.5 6.5a8 8 0 0 1 0 11"/></svg>

After

Width:  |  Height:  |  Size: 250 B

+3
View File
@@ -0,0 +1,3 @@
fn main() {
slint_build::compile("ui/app.slint").expect("failed to compile Slint UI");
}
+728
View File
@@ -0,0 +1,728 @@
//! Slint presentation and the adapter to the application/backend state flow.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use std::thread;
use furumi_application::{
AppEvent, AppState, Effect, Screen, UiAction, duration_label, reduce_action, reduce_event,
};
use furumi_backend::BackendHandle;
use furumi_backend_api::{
BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation,
PlaybackStatus, RemoteData,
};
use furumi_domain::{
Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId,
Release, ReleaseKey, Track, TrackKey,
};
use furumi_platform_desktop::{MediaCommand, MediaSession};
use slint::{
ComponentHandle, Image, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel,
};
slint::include_modules!();
mod render;
use render::{
breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model,
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,
};
thread_local! {
/// Decoded images belong to the Slint UI thread. Keeping them here makes
/// every state projection after the first load a memory-only operation.
static ARTWORK_CACHE: RefCell<HashMap<PathBuf, Option<Image>>> = RefCell::new(HashMap::new());
static MEDIA_SESSION: RefCell<Option<MediaSession>> = const { RefCell::new(None) };
}
/// Runs the desktop presentation until its main window closes.
///
/// # Errors
///
/// Returns a Slint platform error when the window or event loop cannot start.
pub fn run(backend: &BackendHandle) -> Result<(), slint::PlatformError> {
let window = AppWindow::new()?;
let state = Arc::new(Mutex::new(AppState::default()));
let media_backend = backend.clone();
MEDIA_SESSION.with_borrow_mut(|session| {
*session = MediaSession::new(move |command| {
let backend_command = match command {
MediaCommand::Toggle => BackendCommand::TogglePlayback,
MediaCommand::Play => BackendCommand::Play,
MediaCommand::Pause => BackendCommand::Pause,
MediaCommand::Next => BackendCommand::Next,
MediaCommand::Previous => BackendCommand::Previous,
MediaCommand::Stop => BackendCommand::Stop,
};
let _ = media_backend.try_send(backend_command);
});
});
bind_callbacks(&window, &state, backend);
subscribe_to_backend(&window, &state, backend);
with_state(&state, |state| render(&window, state));
if let Err(error) = backend.try_send(BackendCommand::Initialize) {
dispatch_event(
&window,
&state,
AppEvent::CommandRejected(error.to_string()),
);
}
let result = window.run();
MEDIA_SESSION.with_borrow_mut(Option::take);
let _ = backend.try_send(BackendCommand::Shutdown);
result
}
fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
bind_catalog_callbacks(window, state, backend);
window.on_navigate({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |target| {
let screen = match target.as_str() {
"search" => Screen::Search,
"library" => Screen::Library,
_ => Screen::Home,
};
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
}
});
window.on_toggle_queue({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ToggleQueue)
});
window.on_toggle_settings({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ToggleSettings)
});
bind_player_callbacks(window, state, backend);
window.on_search_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |query| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SearchChanged(query.to_string()),
);
}
});
window.on_track_action({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |action, key| {
let Some(key) = parse_track_key(&key) else {
return;
};
let action = match action.as_str() {
"like" => UiAction::ToggleLike(key),
"play-next" => UiAction::AddNext(vec![key]),
"add-end" => UiAction::AddToEnd(vec![key]),
"information" => UiAction::ShowTrackInfo(key),
"playlist" => UiAction::ShowPlaylistPicker(key),
// The remaining presentation actions terminate here until
// their backend capabilities are introduced.
_ => return,
};
dispatch_action(&window, &state, &backend, action);
}
});
bind_playlist_callbacks(window, state, backend);
bind_device_callbacks(window, state, backend);
bind_settings_callbacks(window, state, backend);
window.on_dismiss_error({
let window = window.as_weak();
let state = Arc::clone(state);
move || {
let Some(window) = window.upgrade() else {
return;
};
with_state(&state, |state| {
let _ = reduce_action(state, UiAction::DismissError);
render(&window, state);
});
}
});
window.on_close_track_info({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::CloseTrackInfo)
});
}
fn bind_playlist_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_open_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |id| {
if let Ok(id) = id.parse::<i64>() {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Playlist(id)),
);
}
}
});
window.on_create_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |title| {
dispatch_action(
&window,
&state,
&backend,
UiAction::CreatePlaylist(title.to_string()),
);
}
});
window.on_add_track_to_playlist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |playlist_id| {
let Ok(playlist_id) = playlist_id.parse::<i64>() else {
return;
};
let track = with_state(&state, |state| state.frontend.playlist_picker_track.clone());
if let Some(track) = track {
dispatch_action(
&window,
&state,
&backend,
UiAction::AddToPlaylist {
playlist_id,
tracks: vec![track],
},
);
}
}
});
window.on_close_playlist_picker({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker)
});
window.on_play_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::PlayQueueItem(QueueItemId::new(id)),
);
}
}
});
}
fn bind_catalog_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_layout_detail_contributors({
let window = window.as_weak();
let state = Arc::clone(state);
move |width| {
let lines = with_state(&state, |state| {
let Some(release) = selected_release(state) else {
return Vec::new();
};
let preferred = match &state.frontend.screen {
Screen::Release(_, preferred) => preferred.as_ref(),
_ => None,
};
let (_, contributors) = release_artist_credits(&release, preferred);
contributor_lines(&contributors, width)
});
if let Some(window) = window.upgrade() {
window.set_detail_contributor_lines(model(lines));
}
}
});
window.on_go_back({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Back)
});
window.on_go_forward({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Forward)
});
window.on_navigate_breadcrumb({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |target| {
let screen = with_state(&state, |state| breadcrumb_screen(state, &target));
if let Some(screen) = screen {
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
}
}
});
window.on_open_artist({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let artist = with_state(&state, |state| find_artist_key(state, &key));
if let Some(key) = artist {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Artist(key)),
);
}
}
});
window.on_open_release({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let (release, preferred_artist) = with_state(&state, |state| {
let preferred_artist = match &state.frontend.screen {
Screen::Artist(key) => Some(key.clone()),
_ => None,
};
(find_release_key(state, &key), preferred_artist)
});
if let Some(key) = release {
dispatch_action(
&window,
&state,
&backend,
UiAction::Navigate(Screen::Release(key, preferred_artist)),
);
}
}
});
}
fn bind_player_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_toggle_playback({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::TogglePlayback)
});
window.on_next({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Next)
});
window.on_previous({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Previous)
});
window.on_seek({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |fraction| {
let duration = with_state(&state, |state| state.backend.playback.duration_seconds);
dispatch_action(
&window,
&state,
&backend,
UiAction::Seek(f64::from(fraction) * duration),
);
}
});
window.on_set_volume({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |volume| {
dispatch_action(&window, &state, &backend, UiAction::SetVolume(volume));
}
});
window.on_play_release({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
let release_id = with_state(&state, |state| find_release_key(state, &key));
if let Some(release_id) = release_id {
dispatch_action(
&window,
&state,
&backend,
UiAction::PlayRelease {
release_id,
start: 0,
},
);
}
}
});
window.on_play_track({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |key| {
if let Some(key) = parse_track_key(&key) {
dispatch_action(&window, &state, &backend, UiAction::PlayTrack(key));
}
}
});
window.on_play_track_context({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |context, selected| {
let Some(selected) = parse_track_key(&selected) else {
return;
};
let tracks = with_state(&state, |state| track_context(state, context.as_str()));
if tracks.iter().any(|track| track.matches(&selected)) {
dispatch_action(
&window,
&state,
&backend,
UiAction::PlayContext { tracks, selected },
);
}
}
});
}
fn bind_settings_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_network_id_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::NetworkIdChanged(value.to_string()),
);
}
});
window.on_device_name_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::DeviceNameChanged(value.to_string()),
);
}
});
window.on_library_path_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |value| {
dispatch_action(
&window,
&state,
&backend,
UiAction::LibraryPathChanged(value.to_string()),
);
}
});
bind_library_picker(window, state, backend);
window.on_federation_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |enabled| {
dispatch_action(
&window,
&state,
&backend,
UiAction::FederationChanged(enabled),
);
}
});
window.on_save_federated_on_listen_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |enabled| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SaveFederatedOnListenChanged(enabled),
);
}
});
window.on_language_changed({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |language| {
dispatch_action(
&window,
&state,
&backend,
UiAction::LanguageChanged(language.to_string()),
);
}
});
}
fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
window.on_choose_library_path({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || {
let initial = with_state(&state, |state| {
PathBuf::from(&state.backend.settings.library_path)
});
let task_window = window.clone();
let task_state = Arc::clone(&state);
let task_backend = backend.clone();
let result = thread::Builder::new()
.name("furumi-folder-picker".into())
.spawn(move || {
let selected =
furumi_platform_desktop::choose_library_directory(Some(&initial));
let Some(selected) = selected else {
return;
};
let selected = selected.to_string_lossy().into_owned();
let _ = task_window.upgrade_in_event_loop(move |window| {
dispatch_action(
&window.as_weak(),
&task_state,
&task_backend,
UiAction::LibraryPathChanged(selected),
);
});
});
if let Err(error) = result
&& let Some(window) = window.upgrade()
{
dispatch_event(
&window,
&state,
AppEvent::CommandRejected(format!("folder picker: {error}")),
);
}
}
});
}
fn bind_device_callbacks(
window: &AppWindow,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
) {
window.on_create_device_invite({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::CreateDeviceInvite)
});
window.on_connect_device({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |invite| {
dispatch_action(
&window,
&state,
&backend,
UiAction::ConnectDevice(invite.to_string()),
);
}
});
window.on_answer_pairing({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |request_id, accept, use_requester_group| {
dispatch_action(
&window,
&state,
&backend,
UiAction::AnswerDevicePairing {
request_id: request_id.to_string(),
accept,
use_requester_group,
},
);
}
});
window.on_select_device({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move |device_id| {
dispatch_action(
&window,
&state,
&backend,
UiAction::SelectPlaybackDevice(device_id.to_string()),
);
}
});
}
fn subscribe_to_backend(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
let mut snapshots = backend.subscribe();
let bridge_window = window.as_weak();
let bridge_state = Arc::clone(state);
let result = thread::Builder::new()
.name("furumi-ui-state-bridge".into())
.spawn(move || {
let runtime = match tokio::runtime::Builder::new_current_thread().build() {
Ok(runtime) => runtime,
Err(error) => {
report_background_error(
&bridge_window,
bridge_state,
format!("UI state bridge: {error}"),
);
return;
}
};
runtime.block_on(async move {
while snapshots.changed().await.is_ok() {
let snapshot = snapshots.borrow_and_update().clone();
let state = Arc::clone(&bridge_state);
let result = bridge_window.upgrade_in_event_loop(move |window| {
dispatch_event(
&window,
&state,
AppEvent::BackendSnapshot(Box::new(snapshot)),
);
});
if result.is_err() {
break;
}
}
});
});
if let Err(error) = result {
dispatch_event(
window,
state,
AppEvent::CommandRejected(format!("UI state bridge: {error}")),
);
}
}
fn report_background_error(
window: &slint::Weak<AppWindow>,
state: Arc<Mutex<AppState>>,
message: String,
) {
let window = window.clone();
let _ = window.upgrade_in_event_loop(move |window| {
dispatch_event(&window, &state, AppEvent::CommandRejected(message));
});
}
fn dispatch_action(
window: &slint::Weak<AppWindow>,
state: &Arc<Mutex<AppState>>,
backend: &BackendHandle,
action: UiAction,
) {
let Some(window) = window.upgrade() else {
return;
};
let effects = with_state(state, |state| {
let effects = reduce_action(state, action);
render(&window, state);
effects
});
for Effect::Send(command) in effects {
if let Err(error) = backend.try_send(command) {
dispatch_event(&window, state, AppEvent::CommandRejected(error.to_string()));
}
}
}
fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEvent) {
with_state(state, |state| {
let previous = state.backend.clone();
reduce_event(state, event);
let shell_changed = previous.federation_activity != state.backend.federation_activity
|| previous.federation_debug != state.backend.federation_debug
|| previous.connected_devices != state.backend.connected_devices
|| previous.settings != state.backend.settings
|| 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 queue_changed = previous.queue != state.backend.queue;
if shell_changed {
render_shell(window, state);
}
if catalog_changed {
render_catalog(window, state);
render_search(window, state);
render_track_info(window, state);
}
if queue_changed {
render_queue(window, state);
render_current_track(window, state);
}
render_playback(window, state);
});
}
fn with_state<T>(state: &Arc<Mutex<AppState>>, operation: impl FnOnce(&mut AppState) -> T) -> T {
let mut guard = state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
operation(&mut guard)
}
File diff suppressed because it is too large Load Diff
+822
View File
@@ -0,0 +1,822 @@
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";
export component AppWindow inherits Window {
title: "Furumi Desktop";
preferred-width: 1180px;
preferred-height: 760px;
min-width: 920px;
min-height: 620px;
background: #090b10;
default-font-family: "sans-serif";
in property <string> app-name: "Furumi";
in property <string> home-label: "Home";
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> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query;
in property <string> active-screen: "home";
in property <bool> queue-open;
in property <bool> settings-open;
in property <string> network-id;
in property <string> device-name;
in property <string> library-path;
in property <bool> federation-enabled;
in property <bool> save-federated-on-listen;
in property <string> selected-language: "English";
in property <[string]> available-languages;
in property <[ReleaseView]> releases;
in property <[ArtistView]> artists;
in property <[ReleaseView]> artist-albums;
in property <[ReleaseView]> artist-other-releases;
in property <[TrackView]> artist-featured-tracks;
in property <[TrackView]> detail-tracks;
in property <string> detail-title;
in property <string> detail-subtitle;
in property <[ArtistLinkView]> detail-main-artists;
in property <[ArtistLinkLineView]> detail-contributor-lines;
in property <string> detail-release-type;
in property <image> detail-artwork;
in property <bool> detail-has-artwork;
in property <[TrackView]> tracks;
in property <[QueueView]> queue-items;
in property <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks;
in property <string> playlist-title;
in property <bool> playlist-picker-open;
in property <[DeviceView]> connected-devices;
in property <[PairingView]> pending-pairings;
in property <string> device-role-label;
in property <bool> device-is-active;
in property <string> active-device-label;
in property <string> device-group-label;
in property <string> device-invite;
in property <string> device-status;
in property <bool> device-busy;
in property <[TrackView]> search-results;
in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy;
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";
in property <string> federation-debug-dht: "n/a";
in property <string> federation-debug-published: "0 items";
in property <string> federation-debug-endpoint;
in property <[VersionView]> software-versions;
in property <[VersionView]> protocol-versions;
in property <[BreadcrumbView]> breadcrumbs;
in property <bool> can-go-back;
in property <bool> can-go-forward;
in property <bool> track-info-open;
in property <string> track-info-title;
in property <[ArtistLinkView]> track-info-artist-links;
in property <string> track-info-release;
in property <string> track-info-duration;
in property <string> track-info-format;
in property <string> track-info-quality;
in property <string> track-info-source;
in property <string> track-info-content-id;
in property <string> track-info-path;
in property <image> track-info-artwork;
in property <bool> track-info-has-artwork;
in property <int> detail-federation-state;
in property <bool> has-track;
in property <bool> playing;
in property <string> current-title;
in property <[ArtistLinkView]> current-artists;
in property <string> current-metadata;
in property <string> current-release;
in property <string> current-release-key;
in property <image> current-artwork;
in property <bool> current-has-artwork;
in property <string> elapsed;
in property <string> duration;
in property <float> progress;
in property <float> volume;
in property <string> error-message;
callback navigate(string);
callback go-back;
callback go-forward;
callback navigate-breadcrumb(string);
callback open-artist(string);
callback open-release(string);
callback layout-detail-contributors(float);
callback toggle-queue;
callback toggle-settings;
callback toggle-playback;
callback next;
callback previous;
callback seek(float);
callback set-volume(float);
callback play-release(string);
callback play-track(string);
callback play-track-context(string, string);
callback play-queue-item(string);
callback track-action(string, string);
callback open-playlist(string);
callback create-playlist(string);
callback add-track-to-playlist(string);
callback close-playlist-picker;
callback create-device-invite;
callback connect-device(string);
callback answer-pairing(string, bool, bool);
callback select-device(string);
callback search-changed(string);
callback network-id-changed(string);
callback device-name-changed(string);
callback library-path-changed(string);
callback choose-library-path;
callback federation-changed(bool);
callback save-federated-on-listen-changed(bool);
callback language-changed(string);
callback dismiss-error;
callback close-track-info;
Rectangle {
background: #090b10;
HorizontalLayout {
x: 0px; y: 0px; width: parent.width; height: parent.height - 90px;
spacing: 0px;
sidebar := Rectangle {
width: 218px;
background: #101218;
border-color: #232630;
border-width: 0px;
VerticalLayout {
padding: 16px;
spacing: 7px;
HorizontalLayout {
height: 52px;
spacing: 11px;
Rectangle {
width: 36px;
background: transparent;
Image { source: @image-url("../assets/logo.svg"); width: 36px; height: 36px; y: (parent.height - self.height) / 2; }
}
Text { text: root.app-name; color: #f5f6fa; font-size: 18px; font-weight: 800; vertical-alignment: center; }
}
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"); }
Rectangle { height: 12px; background: transparent; }
HorizontalLayout {
height: 28px;
Text { horizontal-stretch: 1; text: "PLAYLISTS"; color: #656b7b; font-size: 10px; font-weight: 700; letter-spacing: 1.2px; vertical-alignment: center; }
Rectangle {
width: 28px; height: 28px; border-radius: 6px;
background: add-playlist-touch.has-hover ? #282c38 : transparent;
Image { source: @image-url("../assets/plus.svg"); width: 16px; height: 16px; x: 6px; y: 6px; }
add-playlist-touch := TouchArea { clicked => new-playlist-popup.show(); }
}
}
ScrollView {
vertical-stretch: 1;
min-height: 0px;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 2px;
for playlist in root.playlists: PlaylistNavButton {
item: playlist;
active: root.active-screen == "playlist" && root.playlist-title == playlist.title;
clicked(id) => root.open-playlist(id);
}
}
}
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 {
x: 16px; y: 250px; width: 250px; height: 132px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 10px; background: #20242e; border-width: 1px; border-color: #3a3f4d;
VerticalLayout {
padding: 14px; spacing: 10px;
Text { text: "New playlist"; color: #f5f6fa; font-size: 14px; font-weight: 700; }
new-playlist-name := LineEdit { placeholder-text: "Playlist name"; }
Button { text: "Create"; clicked => { root.create-playlist(new-playlist-name.text); new-playlist-name.text = ""; new-playlist-popup.close(); } }
}
}
}
}
content := Rectangle {
horizontal-stretch: 1;
background: #0c0e14;
VerticalLayout {
padding-left: 26px;
padding-right: 26px;
padding-top: 18px;
padding-bottom: 16px;
spacing: 17px;
HorizontalLayout {
height: 42px;
IconButton { enabled: root.can-go-back; icon-source: @image-url("../assets/back.svg"); clicked => root.go-back(); }
IconButton { enabled: root.can-go-forward; icon-source: @image-url("../assets/forward.svg"); clicked => root.go-forward(); }
Rectangle { horizontal-stretch: 1; background: transparent; }
SearchField {
width: 280px;
text <=> root.search-query;
placeholder-text: root.search-placeholder;
edited(value) => root.search-changed(value);
}
}
HorizontalLayout {
height: 26px;
Rectangle {
horizontal-stretch: 1; background: transparent; clip: true;
HorizontalLayout {
spacing: 0px;
alignment: start;
for crumb in root.breadcrumbs: Text {
horizontal-stretch: 0;
text: crumb.label;
color: crumb.target == "" ? #aeb4c2 : crumb-touch.has-hover ? #f1f2f6 : #777e8f;
font-size: 13px; font-weight: crumb.target == "" ? 650 : 500; vertical-alignment: center;
crumb-touch := TouchArea { enabled: crumb.target != ""; clicked => root.navigate-breadcrumb(crumb.target); }
}
}
}
Rectangle {
height: 24px;
width: 280px;
border-radius: 12px;
background: root.federation-status-busy ? #20352f : #191d26;
border-color: root.federation-status-busy ? #3d7865 : #303541;
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; }
status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
}
if root.active-screen == "home": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width;
spacing: 17px;
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 == "library": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 17px;
Text { text: root.library-label; color: #f5f6fa; font-size: 28px; font-weight: 800; }
AlbumGrid { releases: root.releases; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
}
}
if root.active-screen == "playlist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 18px; alignment: start;
Text { height: 46px; vertical-stretch: 0; text: root.playlist-title; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; }
Text { height: 18px; vertical-stretch: 0; text: root.playlist-tracks.length + (root.playlist-tracks.length == 1 ? " track" : " tracks"); color: #858b9c; font-size: 12px; vertical-alignment: center; }
TrackList { tracks: root.playlist-tracks; show-artwork: true; play(key) => root.play-track-context("playlist", 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 == "artist": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 20px; alignment: start;
HorizontalLayout {
height: 188px; spacing: 22px;
Rectangle {
width: 188px; height: 188px; border-radius: 30px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
VerticalLayout { alignment: end; spacing: 8px; Text { text: "ARTIST"; color: #9298a8; font-size: 11px; font-weight: 700; } Text { text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; } Text { text: root.detail-subtitle; color: #858b9c; font-size: 13px; } }
}
if root.artist-albums.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Albums"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-albums.length > 0: AlbumGrid { releases: root.artist-albums; show-artists: false; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
if root.artist-other-releases.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Singles, EPs and other releases"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-other-releases.length > 0: AlbumGrid { releases: root.artist-other-releases; show-artists: false; show-release-types: true; play(key) => root.play-release(key); open(key) => root.open-release(key); open-artist(key) => root.open-artist(key); }
if root.artist-featured-tracks.length > 0: Text { height: 28px; vertical-stretch: 0; text: "Featured on"; color: #f5f6fa; font-size: 20px; font-weight: 750; vertical-alignment: center; }
if root.artist-featured-tracks.length > 0: TrackList { tracks: root.artist-featured-tracks; show-artwork: true; play(key) => root.play-track-context("artist-featured", 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 == "release": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 20px;
HorizontalLayout {
height: 188px; spacing: 22px;
Rectangle {
width: 188px; height: 188px; border-radius: 9px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 54px; height: 54px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.detail-has-artwork: Image { source: root.detail-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
Rectangle {
horizontal-stretch: 1;
height: 188px;
background: transparent;
VerticalLayout {
x: 0px; y: 68px; width: parent.width; height: 111px;
spacing: 2px; alignment: start;
HorizontalLayout {
height: 18px; spacing: 8px;
Text { text: root.detail-release-type; color: #9298a8; font-size: 11px; font-weight: 700; vertical-alignment: center; }
if root.detail-federation-state > 0: FederationBadge { partial: root.detail-federation-state == 2; }
Rectangle { horizontal-stretch: 1; background: transparent; }
}
Text { width: parent.width; height: 43px; text: root.detail-title; color: #f5f6fa; font-size: 34px; font-weight: 800; overflow: elide; vertical-alignment: center; }
ArtistLinksRow { width: parent.width; height: 20px; artists: root.detail-main-artists; text-size: 13px; open-artist(key) => root.open-artist(key); }
}
}
}
if root.detail-contributor-lines.length > 0: StaticArtistList {
width: parent.width / 2;
lines: root.detail-contributor-lines;
heading: "Featuring";
open-artist(key) => root.open-artist(key);
changed width => {
root.layout-detail-contributors(self.width / 1px);
}
}
TrackList { tracks: root.detail-tracks; play(key) => root.play-track-context("release", 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 == "search": ScrollView {
viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 14px; alignment: start;
HorizontalLayout {
height: 34px;
Text { text: root.search-query == "" ? "Search your music" : "Results for “" + root.search-query + "”"; color: #f5f6fa; font-size: 25px; font-weight: 800; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
}
if root.search-artists.length > 0: Text { height: 24px; text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
if root.search-artists.length > 0: SearchArtistGrid { artists: root.search-artists; open(key) => root.open-artist(key); }
if root.search-releases.length > 0: Text { height: 24px; text: "Releases"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
if root.search-releases.length > 0: SearchReleaseGrid { releases: root.search-releases; open(key) => root.open-release(key); }
if root.search-results.length > 0: Text { height: 24px; text: "Tracks"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
TrackList { tracks: root.search-results; show-artwork: true; play(key) => root.play-track-context("search", 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.queue-open: queue-panel := Rectangle {
width: 292px;
background: #11141b;
border-color: #252936;
border-width: 1px;
VerticalLayout {
padding: 17px;
spacing: 12px;
HorizontalLayout {
height: 34px;
Text { text: root.queue-label; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-queue(); }
}
Text { text: "Now playing and up next"; color: #777d8d; font-size: 11px; }
VerticalLayout {
spacing: 4px;
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); }
}
}
}
}
}
Rectangle { vertical-stretch: 1; background: transparent; }
}
}
}
player := Rectangle {
x: 0px; y: parent.height - 90px; width: parent.width; height: 90px;
background: #11131a;
border-color: #282b35;
border-width: 1px;
HorizontalLayout {
padding-left: 18px; padding-right: 18px; padding-top: 12px; padding-bottom: 10px;
spacing: 18px;
HorizontalLayout {
width: 260px; spacing: 11px;
Rectangle {
width: 54px; height: 54px; border-radius: 6px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 28px; height: 28px; x: 13px; y: 13px; }
if root.current-has-artwork: Image { source: root.current-artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if root.current-has-artwork: TouchArea { clicked => artwork-popup.show(); }
}
VerticalLayout {
width: 195px;
alignment: center;
Text { width: 195px; text: root.current-title; color: #f3f4f8; font-size: 13px; font-weight: 650; overflow: elide; }
ArtistLinksRow { width: 195px; height: 16px; artists: root.current-artists; row-color: #818797; open-artist(key) => root.open-artist(key); }
Text { width: 195px; text: root.current-metadata; color: #656b7b; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
}
VerticalLayout {
horizontal-stretch: 1; spacing: 7px;
HorizontalLayout {
alignment: center; spacing: 8px;
IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); }
Rectangle {
width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8;
if root.playing: Image { source: @image-url("../assets/pause.svg"); width: 19px; height: 19px; x: 11px; y: 11px; }
if !root.playing: Image { source: @image-url("../assets/play.svg"); width: 19px; height: 19px; x: 12px; y: 11px; }
TouchArea { clicked => root.toggle-playback(); }
}
IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); }
}
HorizontalLayout {
spacing: 9px;
Text { text: root.elapsed; color: #818797; font-size: 10px; width: 34px; horizontal-alignment: right; vertical-alignment: center; }
Slider { horizontal-stretch: 1; height: 18px; minimum: 0; maximum: 1; value: root.progress; changed(value) => root.seek(value); }
Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; }
}
}
Rectangle {
width: 238px; background: transparent;
IconButton { x: 0px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/queue.svg"); clicked => root.toggle-queue(); }
Image { x: 48px; y: (parent.height - self.height) / 2; source: @image-url("../assets/volume.svg"); width: 18px; height: 18px; }
Slider { x: 75px; y: (parent.height - self.height) / 2; width: 100px; height: 18px; minimum: 0; maximum: 1; value: root.volume; changed(value) => root.set-volume(value); }
IconButton {
x: 190px; y: (parent.height - self.height) / 2;
icon-source: @image-url("../assets/devices.svg");
icon-color: root.device-is-active ? #e58bd7 : transparent;
clicked => devices-popup.show();
}
devices-popup := PopupWindow {
x: -130px; y: -474px; width: 368px; height: 464px;
close-policy: close-on-click-outside;
Rectangle {
border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d;
VerticalLayout {
padding: 16px; spacing: 10px;
HorizontalLayout {
height: 36px;
VerticalLayout {
Text { text: "Connected devices"; color: #f5f6fa; font-size: 17px; font-weight: 750; }
Text { text: root.device-role-label + " · " + root.active-device-label; color: #7f8797; font-size: 10px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => devices-popup.close(); }
}
Text { text: root.device-group-label; color: #697080; font-size: 10px; overflow: elide; }
Rectangle { height: 1px; background: #303541; }
Text { height: 16px; text: "ACTIVE DEVICE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; }
for device in root.connected-devices: DeviceRow {
visible: device.active;
height: device.active ? 48px : 0px;
item: device;
selectable: false;
}
Text { height: 16px; text: "AVAILABLE DEVICES"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.8px; vertical-alignment: center; }
ScrollView {
vertical-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for device in root.connected-devices: DeviceRow {
visible: !device.active;
height: !device.active ? 48px : 0px;
item: device;
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; }
HorizontalLayout {
spacing: 8px;
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
Button { text: "Connect"; enabled: !root.device-busy && device-link.text != ""; clicked => root.connect-device(device-link.text); }
}
HorizontalLayout {
Button { text: root.device-invite == "" ? "Create invite" : "New invite"; enabled: !root.device-busy; clicked => root.create-device-invite(); }
Rectangle { horizontal-stretch: 1; background: transparent; }
Text { text: root.device-status; color: root.device-status == "" ? transparent : #8c93a3; font-size: 9px; vertical-alignment: center; overflow: elide; }
}
}
}
}
}
}
}
}
if root.playlist-picker-open: Rectangle {
background: #05070ab8;
TouchArea { clicked => root.close-playlist-picker(); }
Rectangle {
width: 360px; height: min(430px, parent.height - 80px);
x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2;
border-radius: 12px; background: #1b1f28; border-width: 1px; border-color: #3a3f4d;
TouchArea { }
VerticalLayout {
padding: 18px; spacing: 12px;
HorizontalLayout {
height: 34px;
Text { horizontal-stretch: 1; text: "Add to playlist"; color: #f5f6fa; font-size: 18px; font-weight: 750; vertical-alignment: center; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-playlist-picker(); }
}
Rectangle { height: 1px; background: #303541; }
ScrollView {
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 4px;
for playlist in root.playlists: Rectangle {
visible: !playlist.is-likes;
height: !playlist.is-likes ? 48px : 0px; border-radius: 7px; background: picker-touch.has-hover ? #292e3a : transparent;
VerticalLayout {
padding-left: 10px; padding-right: 10px; alignment: center;
Text { text: playlist.title; color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; }
Text { text: playlist.details; color: #757c8c; font-size: 10px; }
}
picker-touch := TouchArea { clicked => root.add-track-to-playlist(playlist.id); }
}
}
}
}
}
}
if root.settings-open: Rectangle {
background: #05070ad9;
TouchArea { clicked => root.toggle-settings(); }
settings-card := Rectangle {
width: min(620px, parent.width - 48px);
height: min(650px, parent.height - 48px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 14px;
background: #171a22;
border-color: #303541;
border-width: 1px;
// Consume clicks anywhere inside the card so they do not reach the
// modal backdrop. Interactive controls below remain on top.
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
height: 38px;
VerticalLayout {
Text { text: "Settings"; color: #f5f6fa; font-size: 23px; font-weight: 800; }
Text { text: "Desktop player preferences"; color: #7f8595; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-settings(); }
}
Rectangle { height: 1px; background: #2a2e38; }
ScrollView {
vertical-stretch: 1;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
width: parent.width; spacing: 14px; alignment: start;
VerticalLayout {
spacing: 7px;
Text { text: "Network ID"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Peers with the same value form one logical federation network."; color: #818797; font-size: 11px; }
LineEdit {
text: root.network-id;
placeholder-text: "furumi";
edited => root.network-id-changed(self.text);
}
}
VerticalLayout {
spacing: 7px;
Text { text: "Device name"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Shown to your other players when connecting devices or transferring playback."; color: #818797; font-size: 11px; }
LineEdit {
text: root.device-name;
placeholder-text: "Furumi on this computer";
edited => root.device-name-changed(self.text);
}
}
VerticalLayout {
spacing: 7px;
Text { text: "Library path"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Music and downloaded tracks will be stored in this directory."; color: #818797; font-size: 11px; }
HorizontalLayout {
spacing: 8px;
LineEdit {
text: root.library-path;
placeholder-text: "~/Music/Furumi";
horizontal-stretch: 1;
edited => root.library-path-changed(self.text);
}
Button { text: "Choose…"; clicked => root.choose-library-path(); }
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Federation"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Discover and play music shared by peers."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.federation-enabled;
toggled => root.federation-changed(self.checked);
}
}
Rectangle {
min-height: 104px; max-height: 104px; vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 6px;
HorizontalLayout {
height: 18px;
Text { horizontal-stretch: 1; text: "Federation diagnostics"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { text: root.federation-debug-node; color: root.federation-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; }
}
HorizontalLayout {
height: 18px; spacing: 14px;
Text { width: 250px; text: "Peers " + root.federation-debug-peers; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: "DHT " + root.federation-debug-dht; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
}
HorizontalLayout {
height: 18px; spacing: 14px;
Text { width: 250px; text: "Published " + root.federation-debug-published; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: "Endpoint " + root.federation-debug-endpoint; color: #697080; font-size: 9px; overflow: elide; vertical-alignment: center; }
}
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Keep federated tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Import music played from peers and make it available to the federation."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
CheckBox {
text: "Enabled";
checked: root.save-federated-on-listen;
toggled => root.save-federated-on-listen-changed(self.checked);
}
}
HorizontalLayout {
height: 52px;
VerticalLayout {
Text { text: "Language"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
Text { text: "Interface language."; color: #818797; font-size: 11px; }
}
Rectangle { horizontal-stretch: 1; background: transparent; }
ComboBox {
width: 180px;
model: root.available-languages;
current-value: root.selected-language;
selected(value) => root.language-changed(value);
}
}
BuildInfoCard {
width: parent.width;
software: root.software-versions;
protocols: root.protocol-versions;
}
}
}
}
}
}
if root.track-info-open: Rectangle {
background: #05070ad9;
TouchArea { clicked => root.close-track-info(); }
info-card := Rectangle {
width: min(680px, parent.width - 48px);
height: 430px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 12px;
background: #171a22;
border-color: #3a3f4d;
border-width: 2px;
TouchArea { }
VerticalLayout {
padding: 22px; spacing: 16px;
HorizontalLayout {
height: 38px;
Text { text: "Track information"; color: #f5f6fa; font-size: 21px; font-weight: 800; vertical-alignment: center; }
Rectangle { horizontal-stretch: 1; background: transparent; }
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.close-track-info(); }
}
Rectangle { height: 1px; background: #2a2e38; }
HorizontalLayout {
spacing: 22px;
Rectangle {
width: 210px; height: 210px; border-radius: 8px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 56px; height: 56px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if root.track-info-has-artwork: Image { source: root.track-info-artwork; image-fit: ImageFit.contain; width: parent.width; height: parent.height; }
}
VerticalLayout {
spacing: 2px;
Text { height: 34px; text: root.track-info-title; color: #f5f6fa; font-size: 20px; font-weight: 750; overflow: elide; vertical-alignment: center; }
TrackInfoArtists { artists: root.track-info-artist-links; open-artist(key) => root.open-artist(key); }
InfoField { label: "Release"; value: root.track-info-release; }
InfoField { label: "Duration"; value: root.track-info-duration; }
InfoField { label: "Format"; value: root.track-info-format; }
InfoField { label: "Audio quality"; value: root.track-info-quality; }
InfoField { label: "Source"; value: root.track-info-source; }
}
}
InfoField { label: "Content ID"; value: root.track-info-content-id; }
InfoField { label: "File / peer"; value: root.track-info-path; }
}
}
}
if root.error-message != "": Rectangle {
x: parent.width - self.width - 18px;
y: 18px;
width: 320px;
height: 62px;
border-radius: 9px;
background: #3b2028;
Text { text: root.error-message; color: #ffd9e0; font-size: 12px; x: 14px; width: parent.width - 48px; vertical-alignment: center; wrap: word-wrap; }
Text { text: "×"; color: #ffd9e0; x: parent.width - 30px; width: 20px; horizontal-alignment: center; vertical-alignment: center; }
TouchArea { clicked => root.dismiss-error(); }
}
artwork-popup := PopupWindow {
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
width: 560px;
height: 560px;
close-policy: close-on-click-outside;
Rectangle {
width: parent.width; height: parent.height;
border-radius: 8px;
background: #171a22;
border-color: #3a3f4d;
border-width: 2px;
clip: true;
Image { x: 8px; y: 8px; source: root.current-artwork; width: parent.width - 16px; height: parent.height - 16px; image-fit: ImageFit.contain; }
}
}
}
+702
View File
@@ -0,0 +1,702 @@
import { ScrollView } from "std-widgets.slint";
import { ArtistLinkLineView, ArtistLinkView, ArtistView, DeviceView, PlaylistView, ReleaseView, TrackView, VersionView } from "models.slint";
export component IconButton inherits Rectangle {
in property <image> icon-source;
in property <brush> icon-color: transparent;
in property <string> tooltip;
in property <bool> enabled: true;
callback clicked;
width: 38px;
height: 38px;
border-radius: 19px;
opacity: root.enabled ? 1 : 0.32;
background: root.enabled && touch.has-hover ? #282c38 : transparent;
Image { source: root.icon-source; colorize: root.icon-color; width: 19px; height: 19px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
}
export component DeviceRow inherits Rectangle {
in property <DeviceView> item;
in property <bool> selectable: false;
callback selected(string);
height: 48px;
border-radius: 7px;
opacity: item.revoked ? 0.45 : 1;
background: item.active ? #24483d : selectable && touch.has-hover ? #292e3a : transparent;
HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 10px;
Rectangle {
width: 8px; background: transparent;
Rectangle {
width: 8px; height: 8px; border-radius: 4px;
y: (parent.height - self.height) / 2;
background: root.item.online ? #55dbaa : #555c6c;
}
}
VerticalLayout {
horizontal-stretch: 1; alignment: center;
Text { text: root.item.name + (root.item.is-self ? " · this device" : ""); color: #e8eaf0; font-size: 12px; font-weight: 650; overflow: elide; }
Text { text: root.item.details; color: #757c8c; font-size: 10px; overflow: elide; }
}
if root.item.active: Text { text: "ACTIVE"; color: #55dbaa; font-size: 9px; font-weight: 750; vertical-alignment: center; }
}
touch := TouchArea { enabled: root.selectable; clicked => root.selected(root.item.id); }
}
export component FederationBadge inherits Rectangle {
in property <bool> partial: false;
width: 24px;
height: 18px;
border-radius: 5px;
background: root.partial ? @linear-gradient(90deg, #4bd4a1 0%, #4bd4a1 50%, #e1b84b 50%, #e1b84b 100%) : #4bd4a1;
Image {
source: @image-url("../assets/federation.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
export component SearchField inherits Rectangle {
in-out property <string> text <=> input.text;
in property <string> placeholder-text;
callback edited(string);
forward-focus: input;
height: 38px;
border-radius: 19px;
background: #1b1e27;
border-width: 1px;
border-color: input.has-focus ? #697182 : #1b1e27;
clip: true;
TouchArea { clicked => input.focus(); }
Image {
source: @image-url("../assets/search.svg");
x: 14px;
y: (parent.height - self.height) / 2;
width: 17px;
height: 17px;
}
input-viewport := Rectangle {
x: 41px;
y: 1px;
width: parent.width - 54px;
height: parent.height - 2px;
background: transparent;
clip: true;
if input.text == "": Text {
text: root.placeholder-text;
color: #858c9c;
font-size: 12px;
vertical-alignment: center;
overflow: elide;
}
input := TextInput {
private property <length> cursor-margin: 2px;
width: max(parent.width, self.preferred-width);
height: parent.height;
color: #e8eaf0;
font-size: 12px;
vertical-alignment: center;
single-line: true;
input-type: search;
selection-background-color: #52617a;
selection-foreground-color: #ffffff;
edited => root.edited(self.text);
cursor-position-changed(cursor-position) => {
if cursor-position.x + self.x < cursor-margin {
self.x = -cursor-position.x + cursor-margin;
} else if cursor-position.x + self.x > parent.width - cursor-margin - self.text-cursor-width {
self.x = parent.width - cursor-position.x - cursor-margin - self.text-cursor-width;
}
}
}
}
}
export component NavButton inherits Rectangle {
in property <string> label;
in property <image> icon-source;
in property <bool> active;
callback clicked;
height: 44px;
border-radius: 8px;
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout {
padding-left: 13px;
padding-right: 13px;
spacing: 12px;
Rectangle {
width: 19px;
background: transparent;
Image { source: root.icon-source; width: 19px; height: 19px; y: (parent.height - self.height) / 2; }
}
Text { text: root.label; color: root.active ? #ffffff : #b5bac8; font-size: 14px; font-weight: root.active ? 700 : 500; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(); }
}
export component PlaylistNavButton inherits Rectangle {
in property <PlaylistView> item;
in property <bool> active;
callback clicked(string);
height: 34px;
border-radius: 6px;
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;
}
Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(root.item.id); }
}
export component MarqueeText inherits Rectangle {
in property <string> label;
in property <color> text-color: #858b9c;
in property <length> text-size: 11px;
in property <int> text-weight: 400;
private property <length> travel: max(0px, marquee-label.preferred-width - root.width);
private property <bool> scrolling: root.travel > 1px;
// Calling animation-tick() for text that already fits keeps the whole
// window repainting for no visual reason.
private property <duration> phase: root.scrolling ? mod(animation-tick(), 10s) : 0ms;
clip: true;
background: transparent;
marquee-label := Text {
x: !root.scrolling || root.phase < 1500ms ? 0px
: root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms)
: root.phase < 6000ms ? -root.travel
: root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms))
: 0px;
y: 0px;
width: self.preferred-width;
height: root.height;
text: root.label;
color: root.text-color;
font-size: root.text-size;
font-weight: root.text-weight;
vertical-alignment: center;
horizontal-alignment: left;
}
}
export component LinkText inherits Rectangle {
in property <string> label;
in property <color> base-color: #858b9c;
in property <length> text-size: 11px;
in property <int> text-weight: 400;
callback clicked;
height: 17px;
horizontal-stretch: 1;
background: transparent;
MarqueeText {
x: 0px; y: 0px; width: parent.width; height: parent.height;
label: root.label;
text-color: touch.has-hover ? #f1f2f6 : root.base-color;
text-size: root.text-size;
text-weight: root.text-weight;
}
touch := TouchArea { clicked => root.clicked(); }
}
export component InlineArtistLink inherits Text {
in property <string> key;
in property <color> base-color: #858b9c;
callback clicked(string);
color: touch.has-hover ? #f1f2f6 : root.base-color;
font-size: 11px;
vertical-alignment: center;
touch := TouchArea { clicked => root.clicked(root.key); }
}
export component ArtistLinksRow inherits Rectangle {
in property <[ArtistLinkView]> artists;
in property <string> leading-label;
in property <color> row-color: #858b9c;
in property <length> text-size: 11px;
in property <int> start-index: 0;
in property <int> end-index: artists.length;
in property <bool> animate-overflow: true;
callback open-artist(string);
height: 18px;
background: transparent;
clip: true;
private property <length> travel: max(0px, links.preferred-width - root.width);
private property <duration> phase: root.animate-overflow && root.travel > 1px ? mod(animation-tick(), 10s) : 0ms;
links := HorizontalLayout {
x: !root.animate-overflow || root.travel <= 1px || root.phase < 1500ms ? 0px
: root.phase < 4500ms ? -root.travel * ((root.phase - 1500ms) / 3000ms)
: root.phase < 6000ms ? -root.travel
: root.phase < 9000ms ? -root.travel * (1 - ((root.phase - 6000ms) / 3000ms))
: 0px;
y: 0px; width: max(root.width, self.preferred-width); height: root.height;
spacing: 0px; alignment: start;
if root.leading-label != "": Text { text: root.leading-label; color: root.row-color; font-size: root.text-size; vertical-alignment: center; }
for artist[index] in root.artists: HorizontalLayout {
visible: index >= root.start-index && index < root.end-index;
spacing: 0px;
if artist.prefix != "": Text {
text: index == root.start-index && artist.prefix == ", " ? ""
: index == root.start-index && artist.prefix == " feat. " ? "feat. "
: artist.prefix;
color: root.row-color; font-size: root.text-size; vertical-alignment: center;
}
InlineArtistLink {
key: artist.key; text: artist.name; base-color: root.row-color;
font-size: root.text-size; clicked(key) => root.open-artist(key);
}
}
}
}
export component TrackArtistsCell inherits Rectangle {
in property <[ArtistLinkView]> artists;
in property <string> label;
in property <int> line-break;
callback open-artist(string);
clip: true;
background: transparent;
private property <bool> use-two-lines: root.line-break > 0 && measure.preferred-width > root.width * 1.85;
measure := Text { visible: false; text: root.label; font-size: 11px; }
if !root.use-two-lines: ArtistLinksRow {
width: parent.width; height: parent.height;
artists: root.artists;
open-artist(key) => root.open-artist(key);
}
if root.use-two-lines: Rectangle {
x: 0px; y: 4px; width: parent.width; height: parent.height - 8px;
background: transparent; clip: true;
ArtistLinksRow {
x: 0px; y: 0px; width: parent.width; height: parent.height / 2;
artists: root.artists; end-index: root.line-break; animate-overflow: false;
open-artist(key) => root.open-artist(key);
}
ArtistLinksRow {
x: 0px; y: parent.height / 2; width: parent.width; height: parent.height / 2;
artists: root.artists; start-index: root.line-break; animate-overflow: false;
open-artist(key) => root.open-artist(key);
}
}
}
export component StaticArtistList inherits Rectangle {
in property <[ArtistLinkLineView]> lines;
in property <string> heading;
callback open-artist(string);
height: (heading == "" ? 0px : 20px) + lines.length * 19px;
background: transparent;
if root.heading != "": Text {
x: 0px; y: 0px; width: parent.width; height: 18px;
text: root.heading; color: #707687; font-size: 11px; vertical-alignment: center;
}
for line[index] in root.lines: Rectangle {
x: 0px;
y: (root.heading == "" ? 0px : 20px) + index * 19px;
width: parent.width;
height: 19px;
background: transparent;
HorizontalLayout {
spacing: 0px; alignment: start;
for artist in line.artists: HorizontalLayout {
spacing: 0px;
if artist.prefix != "": Text {
text: artist.prefix; color: #858b9c; font-size: 11px; vertical-alignment: center;
}
InlineArtistLink {
key: artist.key; text: artist.name; base-color: #858b9c;
clicked(key) => root.open-artist(key);
}
}
}
}
}
export component TrackInfoArtists inherits Rectangle {
in property <[ArtistLinkView]> artists;
callback open-artist(string);
height: min(62px, max(28px, artists.length * 20px));
background: transparent;
Text { x: 0px; width: 112px; height: 28px; text: "Artists"; color: #707687; font-size: 11px; vertical-alignment: center; }
ScrollView {
x: 124px; width: parent.width - 124px; height: parent.height;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout {
spacing: 0px;
for artist in root.artists: Rectangle {
height: 20px; background: transparent;
Text {
width: parent.width; height: parent.height;
text: artist.name;
color: artist-touch.has-hover ? #f1f2f6 : #dfe2ea;
font-size: 11px; overflow: elide; vertical-alignment: center;
}
artist-touch := TouchArea { clicked => root.open-artist(artist.key); }
}
}
}
}
export component VersionRows inherits Rectangle {
in property <[VersionView]> items;
height: ceil(items.length / 2) * 21px;
background: transparent;
for item[index] in items: Rectangle {
x: mod(index, 2) * parent.width / 2;
y: floor(index / 2) * 21px;
width: parent.width / 2 - (mod(index, 2) == 0 ? 8px : 0px);
height: 21px;
background: transparent;
Text {
x: 0px; width: parent.width - 82px; height: parent.height;
text: item.name; color: #8f96a6; font-size: 10px;
overflow: elide; vertical-alignment: center;
}
Text {
x: parent.width - 78px; width: 78px; height: parent.height;
text: "v" + item.version; color: #d7dae3; font-size: 10px;
font-weight: 650; overflow: elide; horizontal-alignment: right;
vertical-alignment: center;
}
}
}
export component BuildInfoCard inherits Rectangle {
in property <[VersionView]> software;
in property <[VersionView]> protocols;
min-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px;
max-height: 82px + ceil(software.length / 2) * 21px + ceil(protocols.length / 2) * 21px;
vertical-stretch: 0;
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
VerticalLayout {
padding: 11px; spacing: 5px;
Text { height: 18px; text: "Build and compatibility"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
Text { height: 14px; text: "SOFTWARE"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; }
VersionRows { items: root.software; }
Text { height: 14px; text: "PROTOCOLS"; color: #697080; font-size: 9px; font-weight: 750; letter-spacing: 0.7px; vertical-alignment: center; }
VersionRows { items: root.protocols; }
}
}
export component AlbumTile inherits Rectangle {
in property <ReleaseView> item;
in property <bool> show-artist: true;
in property <bool> show-release-type: false;
callback play(string);
callback open(string);
callback open-artist(string);
private property <bool> hovered: touch.has-hover || play-touch.has-hover;
width: 154px;
height: 216px;
border-radius: 10px;
background: root.hovered ? #20242e : #171a22;
touch := TouchArea { clicked => root.open(item.key); }
VerticalLayout {
padding: 11px;
spacing: 9px;
cover := Rectangle {
height: 132px;
border-radius: 7px;
clip: true;
background: #292d38;
Image { source: @image-url("../assets/note.svg"); width: 44px; height: 44px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; }
Rectangle {
x: 92px; y: 80px; width: 38px; height: 38px;
border-radius: 19px;
background: play-touch.has-hover ? #62e6b4 : #4bd4a1;
opacity: root.hovered ? 1 : 0;
Image { source: @image-url("../assets/play.svg"); width: 18px; height: 18px; x: 10px; y: 10px; }
play-touch := TouchArea { clicked => root.play(item.key); }
}
}
Text { text: item.title; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; }
if root.show-artist: LinkText { label: item.artist + " · " + item.year; text-size: 12px; clicked => root.open-artist(item.artist-key); }
if !root.show-artist: Text {
height: 17px;
text: item.year + (root.show-release-type && item.release-type != "" ? " · " + item.release-type : "");
color: #858b9c; font-size: 12px; overflow: elide; vertical-alignment: center;
}
}
}
export component ArtistTile inherits Rectangle {
in property <ArtistView> item;
callback open(string);
width: 154px;
height: 210px;
border-radius: 10px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 11px;
spacing: 9px;
Rectangle {
height: 132px;
border-radius: 66px;
clip: true;
background: #292d38;
Image { source: @image-url("../assets/note.svg"); width: 42px; height: 42px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
}
Text { text: item.name; color: #f4f5f8; font-size: 14px; font-weight: 700; overflow: elide; }
Text { text: item.details; color: #858b9c; font-size: 11px; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchArtistTile inherits Rectangle {
in property <ArtistView> item;
callback open(string);
width: 120px; height: 150px; border-radius: 8px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 9px; spacing: 7px;
Rectangle {
height: 102px; border-radius: 10px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federated: FederationBadge { x: parent.width - self.width - 4px; y: 4px; }
}
Text { text: item.name; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchReleaseTile inherits Rectangle {
in property <ReleaseView> item;
callback open(string);
width: 120px; height: 150px; border-radius: 8px;
background: touch.has-hover ? #20242e : #171a22;
VerticalLayout {
padding: 9px; spacing: 7px;
Rectangle {
height: 102px; border-radius: 5px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 32px; height: 32px; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; }
if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; }
if item.federation-state > 0: FederationBadge { partial: item.federation-state == 2; x: parent.width - self.width - 4px; y: 4px; }
}
Text { text: item.title; color: #f4f5f8; font-size: 12px; font-weight: 700; overflow: elide; }
}
touch := TouchArea { clicked => root.open(item.key); }
}
export component SearchArtistGrid inherits Rectangle {
in property <[ArtistView]> artists;
callback open(string);
property <int> columns: max(1, floor((self.width + 12px) / 132px));
height: artists.length > 0 ? ceil(artists.length / columns) * 162px - 12px : 0px;
for artist[index] in artists: SearchArtistTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: artist; open(key) => root.open(key); }
}
export component SearchReleaseGrid inherits Rectangle {
in property <[ReleaseView]> releases;
callback open(string);
property <int> columns: max(1, floor((self.width + 12px) / 132px));
height: releases.length > 0 ? ceil(releases.length / columns) * 162px - 12px : 0px;
for release[index] in releases: SearchReleaseTile { x: mod(index, root.columns) * 132px; y: floor(index / root.columns) * 162px; item: release; open(key) => root.open(key); }
}
export component TrackActionButton inherits Rectangle {
in property <image> icon-source;
in property <bool> active: false;
callback clicked;
width: 28px;
height: 28px;
border-radius: 6px;
background: root.active ? #255845 : touch.has-hover ? #353a47 : transparent;
Image { source: root.icon-source; width: 16px; height: 16px; x: 6px; y: 6px; }
touch := TouchArea { clicked => root.clicked(); }
}
export component TrackMenuItem inherits Rectangle {
in property <image> icon-source;
in property <string> label;
callback clicked;
height: 34px;
border-radius: 5px;
background: touch.has-hover ? #303541 : transparent;
HorizontalLayout {
padding-left: 9px; padding-right: 9px; spacing: 9px;
Rectangle {
width: 16px; background: transparent;
Image { source: root.icon-source; width: 16px; height: 16px; y: (parent.height - self.height) / 2; }
}
Text { text: root.label; color: #d9dce5; font-size: 11px; vertical-alignment: center; }
}
touch := TouchArea { clicked => root.clicked(); }
}
export component InfoField inherits Rectangle {
in property <string> label;
in property <string> value;
height: 28px;
background: transparent;
HorizontalLayout {
spacing: 12px;
Text { width: 112px; text: root.label; color: #707687; font-size: 11px; vertical-alignment: center; }
Text { horizontal-stretch: 1; text: root.value == "" ? "—" : root.value; color: #dfe2ea; font-size: 11px; overflow: elide; vertical-alignment: center; }
}
}
export component TrackRow inherits Rectangle {
in property <TrackView> item;
in property <bool> show-artwork: false;
callback play(string);
callback action(string, string);
callback open-artist(string);
callback open-release(string);
height: 48px;
border-radius: 6px;
background: item.active ? #282d3a : touch.has-hover ? #20242e : transparent;
touch := TouchArea { double-clicked => root.play(item.key); }
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
Text { text: item.number; color: #777d8d; width: 24px; vertical-alignment: center; horizontal-alignment: center; }
if root.show-artwork: Rectangle {
width: 34px; height: 34px; border-radius: 4px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 18px; height: 18px; 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;
Rectangle {
x: 0px; width: parent.width * 0.40; height: parent.height; background: transparent; clip: true;
Text {
x: 0px; y: 0px;
// Keep identical text geometry for local and federated
// rows. The badge is an overlay at the trailing edge.
width: parent.width - 34px; height: parent.height;
text: item.title;
color: item.active ? #55dbaa : #f1f2f6;
font-size: 13px; font-weight: 600; overflow: elide;
horizontal-alignment: left;
vertical-alignment: center;
}
if item.federated: FederationBadge {
x: parent.width - self.width - 8px;
y: (parent.height - self.height) / 2;
}
}
Rectangle {
x: parent.width * 0.40; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true;
TrackArtistsCell {
x: 0px; y: 0px; width: parent.width - 12px; height: parent.height;
artists: item.artists; label: item.artists-label;
line-break: item.artist-line-break;
open-artist(key) => root.open-artist(key);
}
}
Rectangle {
x: parent.width * 0.70; width: parent.width * 0.30; height: parent.height; background: transparent; clip: true;
LinkText { x: 0px; y: 0px; width: parent.width - 8px; height: parent.height; label: item.release; base-color: #949aaa; text-size: 12px; clicked => root.open-release(item.release-key); }
}
}
Text { text: item.duration; color: #949aaa; width: 46px; font-size: 12px; horizontal-alignment: right; vertical-alignment: center; }
Rectangle {
width: 124px;
background: transparent;
TrackActionButton { x: 0px; y: (parent.height - self.height) / 2; active: root.item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.action("like", root.item.key); }
TrackActionButton { x: 32px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/play-next.svg"); clicked => root.action("play-next", root.item.key); }
TrackActionButton { x: 64px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/add-end.svg"); clicked => root.action("add-end", root.item.key); }
TrackActionButton { x: 96px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/more.svg"); clicked => { menu.show(); } }
}
}
menu := PopupWindow {
x: root.width - self.width - 8px;
y: 42px;
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 => { 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/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
}
}
}
}
export component TrackList inherits Rectangle {
in property <[TrackView]> tracks;
in property <bool> show-artwork: false;
callback play(string);
callback action(string, string);
callback open-artist(string);
callback open-release(string);
property <length> content-height: tracks.length > 0 ? tracks.length * 50px - 2px : 0px;
min-height: content-height;
preferred-height: content-height;
max-height: content-height;
vertical-stretch: 0;
background: transparent;
for track[index] in tracks: TrackRow {
x: 0px; y: index * 50px; width: root.width;
item: track;
show-artwork: root.show-artwork;
play(key) => root.play(key);
action(action, key) => root.action(action, key);
open-artist(key) => root.open-artist(key);
open-release(key) => root.open-release(key);
}
}
export component AlbumGrid inherits Rectangle {
in property <[ReleaseView]> releases;
in property <bool> show-artists: true;
in property <bool> show-release-types: false;
callback play(string);
callback open(string);
callback open-artist(string);
property <int> fitting-column-count: max(1, floor((self.width + 13px) / 167px));
property <int> column-count: releases.length > 0 && releases.length < fitting-column-count ? releases.length : fitting-column-count;
property <length> column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px;
height: releases.length > 0 ? ceil(releases.length / column-count) * 229px - 13px : 0px;
background: transparent;
for release[index] in releases: AlbumTile {
x: mod(index, root.column-count) * (154px + root.column-gap);
y: floor(index / root.column-count) * 229px;
item: release;
show-artist: root.show-artists;
show-release-type: root.show-release-types;
play(key) => root.play(key);
open(key) => root.open(key);
open-artist(key) => root.open-artist(key);
}
}
export component ArtistGrid inherits Rectangle {
in property <[ArtistView]> artists;
callback open(string);
property <int> fitting-column-count: max(1, floor((self.width + 13px) / 167px));
property <int> column-count: artists.length > 0 && artists.length < fitting-column-count ? artists.length : fitting-column-count;
property <length> column-gap: column-count > 1 ? min(25px, max(13px, (self.width - column-count * 154px) / (column-count - 1))) : 0px;
height: artists.length > 0 ? ceil(artists.length / column-count) * 223px - 13px : 0px;
background: transparent;
for artist[index] in artists: ArtistTile {
x: mod(index, root.column-count) * (154px + root.column-gap);
y: floor(index / root.column-count) * 223px;
item: artist;
open(key) => root.open(key);
}
}
+90
View File
@@ -0,0 +1,90 @@
export struct ReleaseView {
key: string,
title: string,
artist: string,
artist-key: string,
year: string,
release-type: string,
artwork: image,
has-artwork: bool,
federation-state: int,
}
export struct BreadcrumbView { label: string, target: string }
export struct ArtistView {
key: string,
name: string,
details: string,
artwork: image,
has-artwork: bool,
federated: bool,
}
export struct ArtistLinkView {
key: string,
name: string,
prefix: string,
}
export struct ArtistLinkLineView {
artists: [ArtistLinkView],
}
export struct VersionView {
name: string,
version: string,
}
export struct TrackView {
key: string,
number: string,
title: string,
artists: [ArtistLinkView],
artists-label: string,
artist-line-break: int,
release: string,
release-key: string,
duration: string,
active: bool,
artwork: image,
has-artwork: bool,
federated: bool,
liked: bool,
}
export struct QueueView {
key: string,
title: string,
artist: string,
artist-key: string,
release: string,
release-key: string,
active: bool,
artwork: image,
has-artwork: bool,
}
export struct PlaylistView {
id: string,
title: string,
details: string,
is-likes: bool,
}
export struct DeviceView {
id: string,
name: string,
details: string,
is-self: bool,
online: bool,
active: bool,
revoked: bool,
}
export struct PairingView {
request-id: string,
name: string,
details: string,
group-conflict: bool,
}