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
+4
View File
@@ -0,0 +1,4 @@
/target/
/.direnv/
.DS_Store
+74
View File
@@ -0,0 +1,74 @@
# Architecture
Furumi Desktop is a modular monolith: it ships as one process and one binary,
while keeping reusable backend services independent from the desktop UI.
## Invariants
1. The UI renders state and emits intent; it never performs backend work.
2. Application state changes through deterministic reducers.
3. Backend state is authoritative for playback, queue, library and operations.
4. Navigation is frontend state: the backend does not know which panel is open.
5. Commands use a bounded channel; snapshots use a coalescing watch channel.
6. Long operations carry request IDs and cancellation tokens. Stale results are
rejected before they reach authoritative state.
7. Local numeric IDs remain compatible with Furumi (`i64`). Stable track
identity is an optional normalized `b3:<64 hex>` content ID.
8. Audio output runs on a dedicated worker thread. Device access, decoding and
playback never block the UI or backend actor.
9. Durable settings belong to the backend. UI edits are projected immediately,
then persisted by a dedicated worker without blocking the UI or actor loop.
10. Catalog entities use source-aware keys. Local and federated providers map
into the same artist, release, track, and artwork contracts before a
snapshot reaches the application or UI.
11. UI events identify catalog items by stable source-aware keys. A row index
is never used as track identity because federation can reorder a list while
results are arriving.
## Data flow
```text
Slint callback -> UiAction -> reducer -> BackendCommand -> backend actor
^ |
| BackendSnapshot <-----------+
+---- UI projection <- reducer/state update
```
The desktop application uses typed in-process channels. `backend-api` contains
no Slint, Tokio, database or transport types, so a server adapter can map the
same semantics onto another transport without making desktop pay for HTTP.
## Catalog providers and artwork
The local SQLite library and federation are catalog providers, not separate
sets of screens or view models. The backend merges provider results into
`LibrarySnapshot`; `CatalogSource`, `ArtistKey`, and `ReleaseKey` preserve
identity and provenance across that merge. Release tracks are normalized by
disc and metadata track number after every merge, with local records taking
precedence over equivalent remote records.
Artwork is asynchronously resolved. A provider may initially emit an entity
without a URI, fetch or cache its image, and publish a newer snapshot with the
same entity key and a local URI. Slint renders either that resolved image or
the common placeholder and never performs filesystem or network I/O.
## Crates
- `domain`: identifiers, entities and queue rules.
- `backend-api`: commands, snapshots, operation state and errors.
- `application`: frontend navigation state, reducers and UI projections.
- `backend`: actor/runtime orchestration, catalog federation, connected-device
synchronization, persistence and the audio engine.
- `platform-desktop`: narrow native OS adapters such as the folder picker.
- `ui`: Slint components and the adapter connecting callbacks to state.
- `apps/desktop`: composition root only.
## Settings persistence
The backend stores settings in `furumi-desktop.sqlite3` under the platform
application-data directory selected by `directories::ProjectDirs`. Schema
changes are ordered migrations recorded in `schema_migrations`; each migration
runs in a transaction. The settings writer owns its SQLite connection on a
dedicated thread and coalesces bursts of edits before writing the latest full
snapshot. The configured device name is also written to the connected-device
identity and published through the device-profile operation log.
Generated
+9521
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
[workspace]
resolver = "2"
members = [
"apps/desktop",
"crates/application",
"crates/backend",
"crates/backend-api",
"crates/domain",
"crates/platform-desktop",
"crates/ui",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
license = "WTFPL"
authors = ["Furumi contributors"]
description = "A native desktop player for personal music libraries and the Furumi federated network"
readme = "README.md"
[workspace.dependencies]
anyhow = "1.0.102"
slint = { version = "=1.17.1", default-features = false, features = [
"backend-winit",
"renderer-femtovg",
"renderer-software",
"compat-1-2",
] }
slint-build = "=1.17.1"
tokio = { version = "=1.52.3", features = ["rt-multi-thread", "sync", "time", "macros", "io-util", "fs"] }
tokio-util = { version = "=0.7.18", features = ["rt"] }
directories = "6.0.0"
image = { version = "0.25.10", default-features = false, features = ["webp"] }
music-dht = "0.4.0"
serde_json = "1.0.150"
serde = { version = "1.0", features = ["derive"] }
rusqlite = { version = "0.32.1", features = ["bundled"] }
rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] }
rodio = { version = "0.22.2", default-features = false, features = [
"playback",
"mp3",
"flac",
"vorbis",
"wav",
"symphonia-aac",
"symphonia-isomp4",
"symphonia-alac",
] }
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
core-foundation = "0.10.1"
windows-sys = { version = "0.61.2", features = ["Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] }
furumi-application = { path = "crates/application" }
furumi-backend = { path = "crates/backend" }
furumi-backend-api = { path = "crates/backend-api" }
furumi-domain = { path = "crates/domain" }
furumi-library = "0.1.0"
furumi-platform-desktop = { path = "crates/platform-desktop" }
furumi-ui = { path = "crates/ui" }
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = "warn"
pedantic = "warn"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
[patch.crates-io]
# souvlaki 0.8.3 still uses an uninhabited Objective-C FFI marker.
block = { path = "vendor/block" }
+14
View File
@@ -0,0 +1,14 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2026 Furumi contributors
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.
+92
View File
@@ -0,0 +1,92 @@
# Furumi Desktop
![Furumi Desktop](screenshots/furumi-desktop.png)
**Your music. Your devices. Your network.**
Furumi Desktop is a native graphical player for a personal music collection
and the Furumi federated network. It plays the library you keep on your own
computer, discovers music shared by other Furumi players, and streams missing
tracks directly from peers without requiring an account or a central catalog.
Pair your own players and they become one listening environment. Likes,
playlists, the queue, and playback state can move between trusted devices while
each player remains useful on its own.
Furumi Desktop targets **Linux, macOS, and Windows**.
## Why Furumi?
Music you own should not depend on a subscription, a remote account, or one
company's servers. Furumi is built around a different model:
- your local library stays under your control;
- the player works without a cloud backend;
- federation is optional and has no central search service;
- trusted devices synchronize directly with each other;
- missing music can be streamed from an available peer and retained locally.
A single player is a private desktop library. Several players form a resilient
personal music network.
## The player
The desktop interface is focused entirely on listening: artists, releases,
search, playlists, a reusable queue, and familiar playback controls. Local and
federated results share the same views, with clear availability indicators as
remote tracks arrive or become local.
Furumi Desktop also supports OS media keys, background playback, cover art,
trusted-device pairing, playback handoff, and control of another active Furumi
device. It uses the same library format and federation protocols as the Furumi
terminal player.
## Build and run
Rust 1.97 is pinned by `rust-toolchain.toml`.
```bash
cargo build --release --locked
./target/release/furumi-desktop
```
On Debian or Ubuntu, install the native Linux build dependencies first:
```bash
sudo apt install libasound2-dev libfontconfig1-dev libxkbcommon-dev pkg-config
```
A development shell with the Wayland and X11 dependencies is also provided:
```bash
nix-shell
cargo run --bin furumi-desktop
```
macOS and Windows require no additional system packages. Player settings are
available inside the application and are saved automatically.
## Architecture
Furumi Desktop is written in Rust with a reactive Slint interface, SQLite
persistence, Rodio audio playback, and the Frid/Furumi P2P protocol crates. It
ships as one application while keeping domain, application, backend, platform,
and presentation code in separate crates.
More detail is available in [ARCHITECTURE.md](ARCHITECTURE.md).
## Contributing
Bug reports, design discussions, and patches are welcome. Before submitting a
change, run:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
```
## License
Furumi Desktop is released under the
[Do What The Fuck You Want To Public License, Version 2](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "furumi-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description.workspace = true
readme.workspace = true
publish = false
[[bin]]
name = "furumi-desktop"
path = "src/main.rs"
[dependencies]
furumi-backend.workspace = true
furumi-ui.workspace = true
[lints]
workspace = true
+5
View File
@@ -0,0 +1,5 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = furumi_backend::spawn_backend()?;
furumi_ui::run(&backend)?;
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "furumi-application"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
furumi-backend-api.workspace = true
furumi-domain.workspace = true
[lints]
workspace = true
+613
View File
@@ -0,0 +1,613 @@
//! Deterministic frontend state, reducers and UI projections.
use furumi_backend_api::{BackendCommand, BackendSnapshot, PlaybackStatus, RequestId};
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
use std::time::Duration;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Locale {
#[default]
En,
}
#[derive(Debug, Clone, Copy)]
pub struct Strings {
pub app_name: &'static str,
pub home: &'static str,
pub search: &'static str,
pub library: &'static str,
pub queue: &'static str,
pub recently_played: &'static str,
pub made_for_listening: &'static str,
pub empty_queue: &'static str,
pub search_placeholder: &'static str,
}
pub const EN: Strings = Strings {
app_name: "Furumi",
home: "Home",
search: "Search",
library: "Your library",
queue: "Queue",
recently_played: "Recently played",
made_for_listening: "Made for listening",
empty_queue: "Your queue is empty",
search_placeholder: "Artists, albums or tracks",
};
impl Locale {
#[must_use]
pub const fn strings(self) -> &'static Strings {
match self {
Self::En => &EN,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Screen {
#[default]
Home,
Search,
Library,
Artist(ArtistKey),
Release(ReleaseKey, Option<ArtistKey>),
Playlist(i64),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontendState {
pub screen: Screen,
pub navigation_history: Vec<Screen>,
pub navigation_forward: Vec<Screen>,
pub queue_open: bool,
pub settings_open: bool,
pub devices_open: bool,
pub search_query: String,
pub locale: Locale,
pub track_info: Option<TrackKey>,
pub playlist_picker_track: Option<TrackKey>,
}
impl Default for FrontendState {
fn default() -> Self {
Self {
screen: Screen::Home,
navigation_history: Vec::new(),
navigation_forward: Vec::new(),
queue_open: false,
settings_open: false,
devices_open: false,
search_query: String::new(),
locale: Locale::En,
track_info: None,
playlist_picker_track: None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AppState {
pub frontend: FrontendState,
pub backend: BackendSnapshot,
pub next_request_id: u64,
pub transient_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UiAction {
Navigate(Screen),
Back,
Forward,
ToggleQueue,
ToggleSettings,
ToggleDevices,
TogglePlayback,
Next,
Previous,
Seek(f64),
SetVolume(f32),
PlayRelease {
release_id: ReleaseKey,
start: usize,
},
PlayTrack(TrackKey),
PlayQueueItem(QueueItemId),
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
},
AddNext(Vec<TrackKey>),
AddToEnd(Vec<TrackKey>),
ToggleLike(TrackKey),
ShowPlaylistPicker(TrackKey),
ClosePlaylistPicker,
CreatePlaylist(String),
AddToPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
CreateDeviceInvite,
ConnectDevice(String),
AnswerDevicePairing {
request_id: String,
accept: bool,
use_requester_group: bool,
},
SelectPlaybackDevice(String),
SearchChanged(String),
NetworkIdChanged(String),
DeviceNameChanged(String),
LibraryPathChanged(String),
FederationChanged(bool),
SaveFederatedOnListenChanged(bool),
LanguageChanged(String),
ShowTrackInfo(TrackKey),
CloseTrackInfo,
DismissError,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppEvent {
BackendSnapshot(Box<BackendSnapshot>),
CommandRejected(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
Send(BackendCommand),
}
#[allow(
clippy::too_many_lines,
reason = "central exhaustive reducer keeps state transitions together"
)]
pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
match action {
UiAction::Navigate(screen) => {
if state.frontend.screen != screen {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.navigation_forward.clear();
}
state.frontend.screen = screen.clone();
state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id);
match screen {
Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| {
send(BackendCommand::LoadArtist {
request_id,
key,
name: artist.name.clone(),
})
}),
Screen::Release(key, preferred) => {
find_release(state, &key).map_or_else(Vec::new, |release| {
let selected_artist = preferred
.as_ref()
.and_then(|preferred| {
release
.artists
.iter()
.find(|artist| &artist.key == preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
.or_else(|| {
find_artist(state, preferred)
.map(|artist| (artist.key.clone(), artist.name.clone()))
})
})
.or_else(|| {
release
.artists
.first()
.map(|artist| (artist.key.clone(), artist.name.clone()))
});
selected_artist.map_or_else(Vec::new, |(artist_key, artist_name)| {
send(BackendCommand::LoadRelease {
request_id,
key,
artist_key,
artist_name,
})
})
})
}
_ => Vec::new(),
}
}
UiAction::Back => {
if let Some(screen) = state.frontend.navigation_history.pop() {
state
.frontend
.navigation_forward
.push(state.frontend.screen.clone());
state.frontend.screen = screen;
}
Vec::new()
}
UiAction::Forward => {
if let Some(screen) = state.frontend.navigation_forward.pop() {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.screen = screen;
}
Vec::new()
}
UiAction::ToggleQueue => {
state.frontend.queue_open = !state.frontend.queue_open;
Vec::new()
}
UiAction::ToggleSettings => {
state.frontend.settings_open = !state.frontend.settings_open;
if state.frontend.settings_open {
state.frontend.devices_open = false;
}
Vec::new()
}
UiAction::ToggleDevices => {
state.frontend.devices_open = !state.frontend.devices_open;
if state.frontend.devices_open {
state.frontend.settings_open = false;
}
Vec::new()
}
UiAction::TogglePlayback => send(BackendCommand::TogglePlayback),
UiAction::Next => send(BackendCommand::Next),
UiAction::Previous => send(BackendCommand::Previous),
UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }),
UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }),
UiAction::PlayRelease { release_id, start } => {
send(BackendCommand::PlayRelease { release_id, start })
}
UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }),
UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }),
UiAction::PlayContext { tracks, selected } => {
send(BackendCommand::PlayContext { tracks, selected })
}
UiAction::AddNext(tracks) => send(BackendCommand::AddNext { tracks }),
UiAction::AddToEnd(tracks) => send(BackendCommand::AddToEnd { tracks }),
UiAction::ToggleLike(track) => send(BackendCommand::ToggleLike { track }),
UiAction::ShowPlaylistPicker(track) => {
state.frontend.playlist_picker_track = Some(track);
Vec::new()
}
UiAction::ClosePlaylistPicker => {
state.frontend.playlist_picker_track = None;
Vec::new()
}
UiAction::CreatePlaylist(title) => send(BackendCommand::CreatePlaylist { title }),
UiAction::AddToPlaylist {
playlist_id,
tracks,
} => {
state.frontend.playlist_picker_track = None;
send(BackendCommand::AddToPlaylist {
playlist_id,
tracks,
})
}
UiAction::CreateDeviceInvite => send(BackendCommand::CreateDeviceInvite),
UiAction::ConnectDevice(invite) => send(BackendCommand::ConnectDevice { invite }),
UiAction::AnswerDevicePairing {
request_id,
accept,
use_requester_group,
} => send(BackendCommand::AnswerDevicePairing {
request_id,
accept,
use_requester_group,
}),
UiAction::SelectPlaybackDevice(device_id) => {
send(BackendCommand::SelectPlaybackDevice { device_id })
}
UiAction::SearchChanged(query) => {
let previous = active_search_request(&state.backend);
state.frontend.search_query.clone_from(&query);
if state.frontend.screen != Screen::Search {
state
.frontend
.navigation_history
.push(state.frontend.screen.clone());
state.frontend.navigation_forward.clear();
state.frontend.screen = Screen::Search;
}
state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id);
let mut effects = Vec::with_capacity(2);
if let Some(previous) = previous {
effects.push(Effect::Send(BackendCommand::CancelSearch {
request_id: previous,
}));
}
effects.push(Effect::Send(BackendCommand::Search { request_id, query }));
effects
}
UiAction::NetworkIdChanged(value) => {
state.backend.settings.network_id = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::DeviceNameChanged(value) => {
state.backend.settings.device_name = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::LibraryPathChanged(value) => {
state.backend.settings.library_path = value;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::FederationChanged(enabled) => {
state.backend.settings.federation_enabled = enabled;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::SaveFederatedOnListenChanged(enabled) => {
state.backend.settings.save_federated_on_listen = enabled;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::LanguageChanged(language) => {
state.frontend.locale = Locale::En;
state.backend.settings.language = language;
send(BackendCommand::UpdateSettings(
state.backend.settings.clone(),
))
}
UiAction::ShowTrackInfo(track) => {
state.frontend.track_info = Some(track);
Vec::new()
}
UiAction::CloseTrackInfo => {
state.frontend.track_info = None;
Vec::new()
}
UiAction::DismissError => {
state.transient_error = None;
Vec::new()
}
}
}
fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> {
match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None,
}
.into_iter()
.flat_map(|l| l.artists.iter())
.chain(state.backend.search.results.artists.iter())
.find(|a| &a.key == key)
}
fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> {
match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None,
}
.into_iter()
.flat_map(|l| l.featured_releases.iter())
.chain(state.backend.search.results.releases.iter())
.find(|r| &r.key == key)
}
pub fn reduce_event(state: &mut AppState, event: AppEvent) {
match event {
AppEvent::BackendSnapshot(snapshot) => {
if snapshot.revision >= state.backend.revision {
state.backend = *snapshot;
}
}
AppEvent::CommandRejected(message) => state.transient_error = Some(message),
}
}
fn active_search_request(snapshot: &BackendSnapshot) -> Option<RequestId> {
snapshot
.search
.federation_pending
.then_some(snapshot.search.request_id)
.flatten()
}
fn send(command: BackendCommand) -> Vec<Effect> {
vec![Effect::Send(command)]
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlayerProjection {
pub has_track: bool,
pub playing: bool,
pub title: String,
pub artist: String,
pub elapsed: String,
pub duration: String,
pub progress: f32,
pub volume: f32,
}
impl AppState {
#[must_use]
pub fn player_projection(&self) -> PlayerProjection {
let current = self.backend.queue.current().map(|item| &item.track);
let duration = self.backend.playback.duration_seconds.max(0.0);
let elapsed = self.backend.playback.position_seconds.clamp(0.0, duration);
PlayerProjection {
has_track: current.is_some(),
playing: self.backend.playback.status == PlaybackStatus::Playing,
title: current.map_or_else(|| "Nothing playing".into(), |track| track.title.clone()),
artist: current.map_or_else(String::new, |track| track.artist.clone()),
elapsed: duration_label(elapsed),
duration: duration_label(duration),
progress: progress_ratio(elapsed, duration),
volume: self.backend.playback.volume,
}
}
}
#[must_use]
pub fn duration_label(seconds: f64) -> String {
let seconds = if seconds.is_finite() {
seconds.max(0.0)
} else {
0.0
};
let total = Duration::from_secs_f64(seconds).as_secs();
format!("{}:{:02}", total / 60, total % 60)
}
#[allow(
clippy::cast_possible_truncation,
reason = "the clamped unit interval is the precision exposed by Slint"
)]
fn progress_ratio(elapsed: f64, duration: f64) -> f32 {
if duration > 0.0 {
(elapsed / duration).clamp(0.0, 1.0) as f32
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use furumi_backend_api::{SearchResults, SearchSnapshot};
use furumi_domain::{Artist, ArtistRef, Artwork, CatalogSource, Release, ReleaseId};
#[test]
fn a_new_search_cancels_the_active_request() {
let mut state = AppState::default();
state.backend.search = SearchSnapshot {
request_id: Some(RequestId::new(9)),
federation_pending: true,
..SearchSnapshot::default()
};
let effects = reduce_action(&mut state, UiAction::SearchChanged("ambient".into()));
assert_eq!(effects.len(), 2);
assert_eq!(
effects[0],
Effect::Send(BackendCommand::CancelSearch {
request_id: RequestId::new(9)
})
);
assert!(matches!(
effects[1],
Effect::Send(BackendCommand::Search { .. })
));
}
#[test]
fn stale_backend_snapshots_are_ignored() {
let mut state = AppState::default();
state.backend.revision = 8;
reduce_event(
&mut state,
AppEvent::BackendSnapshot(Box::new(BackendSnapshot {
revision: 7,
..BackendSnapshot::default()
})),
);
assert_eq!(state.backend.revision, 8);
}
#[test]
fn detail_navigation_returns_to_the_previous_screen() {
let mut state = AppState::default();
let artist = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "artist-a".into(),
};
reduce_action(&mut state, UiAction::Navigate(Screen::Artist(artist)));
reduce_action(&mut state, UiAction::Back);
assert_eq!(state.frontend.screen, Screen::Home);
assert!(state.frontend.navigation_history.is_empty());
}
#[test]
fn forward_navigation_restores_the_screen_left_by_back() {
let mut state = AppState::default();
let artist = ArtistKey::Federation {
peer_id: "peer-a".into(),
id: "artist-a".into(),
};
let artist_screen = Screen::Artist(artist);
reduce_action(&mut state, UiAction::Navigate(artist_screen.clone()));
reduce_action(&mut state, UiAction::Back);
reduce_action(&mut state, UiAction::Forward);
assert_eq!(state.frontend.screen, artist_screen);
assert!(state.frontend.navigation_forward.is_empty());
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
}
#[test]
fn release_lookup_keeps_the_preferred_artist_name_and_key_together() {
let mut state = AppState::default();
let pasha_key = ArtistKey::Local(furumi_domain::ArtistId::new(1));
let kunteynir_key = ArtistKey::Local(furumi_domain::ArtistId::new(2));
let release_key = ReleaseKey::Local(ReleaseId::new(7));
let artists = [
(kunteynir_key.clone(), "KUNTEYNIR"),
(pasha_key.clone(), "Паша Техник"),
];
state.backend.search.results = SearchResults {
artists: artists
.iter()
.map(|(key, name)| Artist {
key: key.clone(),
source: CatalogSource::Local,
name: (*name).into(),
artwork: Artwork::default(),
release_count: 1,
track_count: 1,
})
.collect(),
releases: vec![Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Порядочный".into(),
artists: artists
.iter()
.map(|(key, name)| ArtistRef {
key: key.clone(),
name: (*name).into(),
})
.collect(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: None,
artwork: Artwork::default(),
tracks: Vec::new(),
}],
tracks: Vec::new(),
};
let effects = reduce_action(
&mut state,
UiAction::Navigate(Screen::Release(release_key, Some(pasha_key.clone()))),
);
assert_eq!(
effects,
vec![Effect::Send(BackendCommand::LoadRelease {
request_id: RequestId::new(1),
key: ReleaseKey::Local(ReleaseId::new(7)),
artist_key: pasha_key,
artist_name: "Паша Техник".into(),
})]
);
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "furumi-backend-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
furumi-domain.workspace = true
[lints]
workspace = true
+346
View File
@@ -0,0 +1,346 @@
//! Transport-independent contract between the frontend application and backend.
use std::fmt;
use furumi_domain::{Artist, Queue, QueueItemId, Release, ReleaseKey, Track, TrackKey};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RequestId(u64);
impl RequestId {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PlaybackStatus {
#[default]
Stopped,
Playing,
Paused,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PlaybackSnapshot {
pub status: PlaybackStatus,
pub position_seconds: f64,
pub duration_seconds: f64,
pub volume: f32,
}
impl Default for PlaybackSnapshot {
fn default() -> Self {
Self {
status: PlaybackStatus::Stopped,
position_seconds: 0.0,
duration_seconds: 0.0,
volume: 0.72,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum RemoteData<T> {
#[default]
NotRequested,
Loading {
request_id: RequestId,
},
Ready(T),
Failed {
message: String,
},
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct LibrarySnapshot {
pub artists: Vec<Artist>,
pub featured_releases: Vec<Release>,
pub recently_played: Vec<Track>,
pub playlists: Vec<PlaylistSnapshot>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PlaylistSnapshot {
pub id: i64,
pub title: String,
pub is_likes: bool,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DevicePlaybackRole {
#[default]
Active,
Control,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectedDeviceSnapshot {
pub id: String,
pub name: String,
pub client_version: String,
pub is_self: bool,
pub presence: DevicePresence,
pub trust: DeviceTrust,
pub is_active: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DevicePresence {
#[default]
Offline,
Online,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum DeviceTrust {
#[default]
Trusted,
Revoked,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PendingPairingSnapshot {
pub request_id: String,
pub device_id: String,
pub name: String,
pub client_version: String,
pub requester_group_id: Option<String>,
pub requester_group_active_devices: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ConnectedDevicesSnapshot {
pub this_device_id: String,
pub this_device_name: String,
pub group_id: String,
pub role: DevicePlaybackRole,
pub active_device_id: String,
pub active_device_name: String,
pub devices: Vec<ConnectedDeviceSnapshot>,
pub pending_pairings: Vec<PendingPairingSnapshot>,
pub invite: Option<String>,
pub busy: bool,
pub last_sync: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SearchResults {
pub artists: Vec<Artist>,
pub releases: Vec<Release>,
pub tracks: Vec<Track>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SearchStats {
pub tracks: usize,
pub artists: usize,
pub peers_queried: usize,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SearchSnapshot {
pub request_id: Option<RequestId>,
pub results: SearchResults,
pub federation_pending: bool,
pub stats: Option<SearchStats>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FederationOperation {
#[default]
Idle,
Search,
Artist,
Release,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FederationActivitySnapshot {
pub operation: FederationOperation,
pub pending: bool,
pub stats: Option<SearchStats>,
pub error: Option<String>,
}
/// Lightweight live diagnostics for the federation node.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FederationDebugSnapshot {
pub running: bool,
pub endpoint_id: String,
pub dht_node_id: String,
pub connected_peers: usize,
pub known_contacts: usize,
pub stored_dht_records: Option<usize>,
pub published_items: usize,
pub error: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionEntrySnapshot {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildInfoSnapshot {
pub software: Vec<VersionEntrySnapshot>,
pub protocols: Vec<VersionEntrySnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsSnapshot {
pub network_id: String,
pub device_name: String,
pub library_path: String,
pub federation_enabled: bool,
pub save_federated_on_listen: bool,
pub language: String,
}
impl Default for SettingsSnapshot {
fn default() -> Self {
Self {
network_id: "furumi".into(),
device_name: String::new(),
library_path: "~/Music/Furumi".into(),
federation_enabled: true,
save_federated_on_listen: true,
language: "English".into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BackendSnapshot {
pub revision: u64,
pub library: RemoteData<LibrarySnapshot>,
pub playback: PlaybackSnapshot,
pub queue: Queue,
pub search: SearchSnapshot,
pub federation_activity: FederationActivitySnapshot,
pub federation_debug: FederationDebugSnapshot,
pub build_info: BuildInfoSnapshot,
pub connected_devices: ConnectedDevicesSnapshot,
pub settings: SettingsSnapshot,
pub playback_error: Option<String>,
pub settings_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BackendCommand {
Initialize,
TogglePlayback,
Play,
Pause,
Stop,
Seek {
position_seconds: f64,
},
SetVolume {
volume: f32,
},
PlayRelease {
release_id: ReleaseKey,
start: usize,
},
PlayTrack {
track: TrackKey,
},
PlayQueueItem {
item_id: QueueItemId,
},
PlayContext {
tracks: Vec<TrackKey>,
selected: TrackKey,
},
AddNext {
tracks: Vec<TrackKey>,
},
AddToEnd {
tracks: Vec<TrackKey>,
},
ToggleLike {
track: TrackKey,
},
CreatePlaylist {
title: String,
},
RenamePlaylist {
playlist_id: i64,
title: String,
},
DeletePlaylist {
playlist_id: i64,
},
AddToPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
RemoveFromPlaylist {
playlist_id: i64,
tracks: Vec<TrackKey>,
},
CreateDeviceInvite,
ConnectDevice {
invite: String,
},
AnswerDevicePairing {
request_id: String,
accept: bool,
use_requester_group: bool,
},
SelectPlaybackDevice {
device_id: String,
},
Next,
Previous,
Search {
request_id: RequestId,
query: String,
},
CancelSearch {
request_id: RequestId,
},
LoadArtist {
request_id: RequestId,
key: furumi_domain::ArtistKey,
name: String,
},
LoadRelease {
request_id: RequestId,
key: ReleaseKey,
artist_key: furumi_domain::ArtistKey,
artist_name: String,
},
UpdateSettings(SettingsSnapshot),
Shutdown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SendCommandError {
Busy,
Closed,
}
impl fmt::Display for SendCommandError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Busy => formatter.write_str("backend command queue is full"),
Self::Closed => formatter.write_str("backend is unavailable"),
}
}
}
impl std::error::Error for SendCommandError {}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-backend"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
furumi-backend-api.workspace = true
furumi-domain.workspace = true
furumi-library.workspace = true
music-dht.workspace = true
directories.workspace = true
rusqlite.workspace = true
rodio.workspace = true
tokio.workspace = true
tokio-util.workspace = true
serde_json.workspace = true
serde.workspace = true
[lints]
workspace = true
+33
View File
@@ -0,0 +1,33 @@
use std::path::PathBuf;
fn main() {
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let lock_path = manifest_dir.join("../../Cargo.lock");
println!("cargo:rerun-if-changed={}", lock_path.display());
let lock = std::fs::read_to_string(&lock_path).unwrap_or_default();
for (package, variable) in [
("furumi-library", "FURUMI_LIBRARY_VERSION"),
("music-dht", "FURUMI_MUSIC_DHT_VERSION"),
("federation-net", "FURUMI_FEDERATION_NET_VERSION"),
] {
println!(
"cargo:rustc-env={variable}={}",
package_version(&lock, package).unwrap_or("unknown")
);
}
}
fn package_version<'a>(lock: &'a str, package: &str) -> Option<&'a str> {
lock.split("[[package]]").find_map(|section| {
let mut name = None;
let mut version = None;
for line in section.lines().map(str::trim) {
if let Some(value) = line.strip_prefix("name = \"") {
name = value.strip_suffix('"');
} else if let Some(value) = line.strip_prefix("version = \"") {
version = value.strip_suffix('"');
}
}
(name == Some(package)).then_some(version).flatten()
})
}
+481
View File
@@ -0,0 +1,481 @@
use super::{
Actor, ArtistKey, ArtistRef, AudioSource, ConnectedDeviceSnapshot, ConnectedDevicesSnapshot,
ContentId, ControlPlaybackAnchor, DeviceOperationResult, DevicePlaybackRole, DevicePresence,
DeviceTrust, Duration, Instant, InternalEvent, PendingControlState, PendingPairingSnapshot,
PlaybackStatus, ReleaseKey, Track, TrackKey, library_track, normalize_device_name,
portable_playback_placeholder, track_to_playback_track, unix_time_ms, volume_percent,
};
impl Actor {
pub(super) fn create_device_invite(&mut self) {
let Some(service) = self.device_service.clone() else {
self.state.connected_devices.error =
Some("Federation network is still starting".into());
self.publish();
return;
};
self.state.connected_devices.busy = true;
self.state.connected_devices.error = None;
self.publish();
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = devices
.create_invite(service)
.await
.map(DeviceOperationResult::Invite)
.map_err(|error| format!("{error:#}"));
let _ = internal
.send(InternalEvent::DeviceOperationFinished(result))
.await;
});
}
pub(super) fn connect_device(&mut self, invite: String) {
let Some(service) = self.device_service.clone() else {
self.state.connected_devices.error =
Some("Federation network is still starting".into());
self.publish();
return;
};
self.state.connected_devices.busy = true;
self.state.connected_devices.error = None;
self.publish();
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = devices
.connect_invite(service, &invite)
.await
.map(DeviceOperationResult::Connected)
.map_err(|error| format!("{error:#}"));
let _ = internal
.send(InternalEvent::DeviceOperationFinished(result))
.await;
});
}
pub(super) fn apply_local_device_name(&mut self, name: &str) {
let name = normalize_device_name(name);
if let Err(error) = self.devices.set_device_name(&name, None) {
self.state.connected_devices.error = Some(format!("device name: {error:#}"));
return;
}
if self.active_device_id == self.state.connected_devices.this_device_id {
self.active_device_name = name;
}
self.refresh_connected_devices();
}
pub(super) fn schedule_device_name_publish(&self, candidate: String) {
let internal = self.internal.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
let _ = internal
.send(InternalEvent::DeviceNamePublishDue(candidate))
.await;
});
}
pub(super) fn publish_device_name(&self, name: String) {
let Some(service) = self.device_service.clone() else {
return;
};
let devices = std::sync::Arc::clone(&self.devices);
let internal = self.internal.clone();
tokio::spawn(async move {
let result = async {
let ticket = service
.ticket()
.await
.map_err(|error| format!("device name: {error:#}"))?;
devices
.set_device_name(&name, Some(&ticket.to_string()))
.map_err(|error| format!("device name: {error:#}"))
}
.await;
let _ = internal
.send(InternalEvent::DeviceNamePublished(result))
.await;
});
}
pub(super) fn refresh_connected_devices(&mut self) {
let status = self.devices.status();
let invite = self.state.connected_devices.invite.take();
let busy = self.state.connected_devices.busy;
let existing_error = self.state.connected_devices.error.take();
self.state.connected_devices = ConnectedDevicesSnapshot {
this_device_id: status.this_device_id,
this_device_name: status.this_device_name,
group_id: status.group_id,
role: self.device_role,
active_device_id: self.active_device_id.clone(),
active_device_name: self.active_device_name.clone(),
devices: status
.devices
.into_iter()
.filter(|device| !device.revoked)
.map(|device| ConnectedDeviceSnapshot {
is_active: device.id == self.active_device_id,
id: device.id,
name: device.name,
client_version: device.client_version,
is_self: device.is_self,
presence: if device.online {
DevicePresence::Online
} else {
DevicePresence::Offline
},
trust: if device.revoked {
DeviceTrust::Revoked
} else {
DeviceTrust::Trusted
},
})
.collect(),
pending_pairings: status
.pending
.into_iter()
.map(|pending| PendingPairingSnapshot {
request_id: pending.request_id,
device_id: pending.device_id,
name: pending.name,
client_version: pending.client_version,
requester_group_id: pending.requester_group_id,
requester_group_active_devices: pending.requester_group_active_devices,
})
.collect(),
invite,
busy,
last_sync: status.last_sync,
error: existing_error.or(status.error),
};
}
pub(super) fn select_playback_device(&mut self, device_id: &str) {
// Selecting the device that already owns playback must not rebuild or
// restart the current audio stream.
if device_id == self.active_device_id {
return;
}
let target = self
.state
.connected_devices
.devices
.iter()
.find(|device| device.id == device_id && device.trust == DeviceTrust::Trusted)
.cloned();
let Some(target) = target else {
return;
};
let wire = self.playback_wire_state();
let previous = self.active_device_id.clone();
let mut urgent_targets = Vec::with_capacity(2);
if target.is_self {
if previous != target.id {
let command = music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id: target.id.clone(),
active_device_name: target.name.clone(),
state: wire.clone(),
};
if let Err(error) = self.devices.record_playback_command(&previous, command) {
self.state.connected_devices.error = Some(format!("device handoff: {error:#}"));
self.publish();
return;
}
urgent_targets.push(previous.clone());
}
self.device_role = DevicePlaybackRole::Active;
self.active_device_id = target.id;
self.active_device_name = target.name;
self.control_anchor = None;
self.pending_control = None;
if self.state.playback.status == PlaybackStatus::Playing {
self.play_current();
}
} else {
let command = music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id: target.id.clone(),
active_device_name: target.name.clone(),
state: wire.clone(),
};
if let Err(error) = self
.devices
.record_playback_command(&target.id, command.clone())
{
self.state.connected_devices.error = Some(format!("device handoff: {error:#}"));
self.publish();
return;
}
urgent_targets.push(target.id.clone());
if previous != target.id && previous != self.state.connected_devices.this_device_id {
if let Err(error) = self.devices.record_playback_command(&previous, command) {
self.state.connected_devices.error =
Some(format!("previous device handoff: {error:#}"));
}
urgent_targets.push(previous);
}
self.audio.stop();
self.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&target.id);
self.active_device_name = target.name;
self.control_anchor = Some(ControlPlaybackAnchor {
device_id: target.id.clone(),
state: wire.clone(),
observed_at: Instant::now(),
});
self.pending_control = Some(PendingControlState {
device_id: target.id,
state: wire,
seek: true,
sent_at: Instant::now(),
});
}
self.devices.request_sync();
if let Some(service) = self.device_service.clone() {
urgent_targets.sort();
urgent_targets.dedup();
for target_id in urgent_targets {
let devices = std::sync::Arc::clone(&self.devices);
let service = std::sync::Arc::clone(&service);
tokio::spawn(async move {
let _ = devices.sync_target(service, &target_id).await;
});
}
}
self.refresh_connected_devices();
self.publish_device_playback();
self.publish();
}
pub(super) fn playback_wire_state(&self) -> music_dht::device_sync::PlaybackStateWire {
music_dht::device_sync::PlaybackStateWire {
queue: self
.state
.queue
.items()
.iter()
.map(|item| track_to_playback_track(&item.track))
.collect(),
queue_pos: self.state.queue.current_index().unwrap_or(0),
playing: self.state.playback.status != PlaybackStatus::Stopped,
paused: self.state.playback.status == PlaybackStatus::Paused,
idle_since_ms: (self.state.playback.status != PlaybackStatus::Playing)
.then_some(unix_time_ms()),
position_secs: self.state.playback.position_seconds,
volume: volume_percent(self.state.playback.volume),
shuffle: false,
repeat: music_dht::device_sync::PlaybackRepeat::Off,
}
}
pub(super) fn publish_device_playback(&self) {
let snapshot = music_dht::device_sync::PlaybackSnapshot {
device_id: self.state.connected_devices.this_device_id.clone(),
device_name: self.state.connected_devices.this_device_name.clone(),
active: self.device_role == DevicePlaybackRole::Active,
updated_at_ms: unix_time_ms(),
state: self.playback_wire_state(),
};
self.devices.publish_playback(snapshot);
}
pub(super) fn send_control_state(&mut self, seek: bool) {
if self.device_role != DevicePlaybackRole::Control {
return;
}
let state = self.playback_wire_state();
let command = music_dht::device_sync::PlaybackCommand::SetState {
state: state.clone(),
seek,
};
if let Err(error) = self
.devices
.record_playback_command(&self.active_device_id, command)
{
self.state.connected_devices.error = Some(format!("device control: {error:#}"));
} else {
self.pending_control = Some(PendingControlState {
device_id: self.active_device_id.clone(),
state,
seek,
sent_at: Instant::now(),
});
}
}
pub(super) fn apply_device_playback_state(
&mut self,
wire: &music_dht::device_sync::PlaybackStateWire,
start_audio: bool,
seek: bool,
) {
let tracks = wire
.queue
.iter()
.filter_map(|track| self.resolve_playback_track(track))
.collect::<Vec<_>>();
if !tracks.is_empty() {
self.state.queue.replace_context(tracks, wire.queue_pos);
self.resolve_queue_artwork();
}
self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0;
self.state.playback.position_seconds = wire.position_secs.max(0.0);
self.state.playback.duration_seconds = self
.state
.queue
.current()
.map_or(0.0, |item| item.track.duration_seconds);
self.state.playback.status = if !wire.playing {
PlaybackStatus::Stopped
} else if wire.paused {
PlaybackStatus::Paused
} else {
PlaybackStatus::Playing
};
self.audio.set_volume(self.state.playback.volume);
if !start_audio {
return;
}
if wire.playing {
self.play_current();
if seek || wire.position_secs > 0.0 {
self.audio
.seek(Duration::from_secs_f64(wire.position_secs.max(0.0)));
self.state.playback.position_seconds = wire.position_secs.max(0.0);
}
if wire.paused {
self.audio.pause();
self.state.playback.status = PlaybackStatus::Paused;
}
} else {
self.audio.stop();
}
}
pub(super) fn apply_device_playback_command(
&mut self,
command: music_dht::device_sync::PlaybackCommand,
) {
match command {
music_dht::device_sync::PlaybackCommand::SetState { state, seek } => {
self.device_role = DevicePlaybackRole::Active;
self.active_device_id = self.state.connected_devices.this_device_id.clone();
self.active_device_name = self.state.connected_devices.this_device_name.clone();
self.control_anchor = None;
self.pending_control = None;
self.apply_device_playback_state(&state, true, seek);
}
music_dht::device_sync::PlaybackCommand::ActiveChanged {
active_device_id,
active_device_name,
state,
} => {
let is_self = active_device_id == self.state.connected_devices.this_device_id;
self.active_device_id = active_device_id;
self.active_device_name = active_device_name;
self.device_role = if is_self {
DevicePlaybackRole::Active
} else {
DevicePlaybackRole::Control
};
self.pending_control = None;
self.control_anchor = (!is_self).then(|| ControlPlaybackAnchor {
device_id: self.active_device_id.clone(),
state: state.clone(),
observed_at: Instant::now(),
});
if !is_self {
self.audio.stop();
}
self.apply_device_playback_state(&state, is_self, true);
}
}
self.refresh_connected_devices();
self.publish();
}
pub(super) fn resolve_playback_track(
&self,
wire: &music_dht::device_sync::PlaybackTrack,
) -> Option<Track> {
if let Some(content_id) = wire
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok())
{
let key = TrackKey::remote(content_id.clone());
if let Some(track) = self.track(&key) {
return Some(track.clone());
}
if let Ok(Some(track)) = self.catalog.track_by_content_id(content_id.as_str()) {
return Some(library_track(track, ""));
}
}
let Some(fed) = wire.fed.as_ref() else {
let content_id = wire
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok())?;
return Some(portable_playback_placeholder(wire, content_id));
};
let content_id = ContentId::parse(&fed.content_id).ok()?;
let release_key = ReleaseKey::Federation {
peer_id: fed.owner.clone(),
id: format!(
"name:{}",
music_dht::normalize_name(fed.release_title.as_deref().unwrap_or_default())
),
};
let refs = |names: &[String]| {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: fed.owner.clone(),
id: format!("name:{}", music_dht::normalize_name(name)),
},
name: name.clone(),
})
.collect::<Vec<_>>()
};
Some(Track {
key: TrackKey::federation(
fed.owner.clone(),
fed.item_id.clone(),
Some(content_id.clone()),
),
title: wire.title.clone(),
artist: wire.artist_names.join(", "),
artists: refs(&wire.artist_names),
featured_artists: refs(&wire.featured_artist_names),
release: wire.release_title.clone(),
release_id: release_key,
duration_seconds: wire.duration_seconds,
track_number: wire
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: wire.audio_format.clone(),
audio_bitrate_kbps: wire
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: wire
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: wire
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: wire
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::Federation {
peer_id: fed.owner.clone(),
content_id,
},
})
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Real audio output isolated from the backend actor and UI thread.
use std::fs::File;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink};
pub trait TrackReadSeek: std::io::Read + std::io::Seek + Send + Sync {}
impl<T> TrackReadSeek for T where T: std::io::Read + std::io::Seek + Send + Sync {}
pub type TrackReader = Box<dyn TrackReadSeek>;
#[derive(Debug)]
pub enum Event {
Started,
Finished,
Failed(String),
}
enum Command {
Play {
path: PathBuf,
volume: f32,
},
PlayStream {
reader: TrackReader,
mime_type: String,
volume: f32,
},
Pause,
Resume,
Stop,
Seek(Duration),
SetVolume(f32),
}
#[derive(Debug, Default)]
pub struct Shared {
position_ms: AtomicU64,
}
impl Shared {
pub fn position_seconds(&self) -> f64 {
Duration::from_millis(self.position_ms.load(Ordering::Relaxed)).as_secs_f64()
}
}
#[derive(Clone)]
pub struct Controller {
commands: Sender<Command>,
pub shared: Arc<Shared>,
}
impl Controller {
pub fn play(&self, path: PathBuf, volume: f32) {
self.shared.position_ms.store(0, Ordering::Relaxed);
let _ = self.commands.send(Command::Play { path, volume });
}
pub fn play_stream(&self, reader: TrackReader, mime_type: String, volume: f32) {
self.shared.position_ms.store(0, Ordering::Relaxed);
let _ = self.commands.send(Command::PlayStream {
reader,
mime_type,
volume,
});
}
pub fn pause(&self) {
let _ = self.commands.send(Command::Pause);
}
pub fn resume(&self) {
let _ = self.commands.send(Command::Resume);
}
pub fn stop(&self) {
let _ = self.commands.send(Command::Stop);
}
pub fn seek(&self, position: Duration) {
let millis = u64::try_from(position.as_millis()).unwrap_or(u64::MAX);
self.shared.position_ms.store(millis, Ordering::Relaxed);
let _ = self.commands.send(Command::Seek(position));
}
pub fn set_volume(&self, volume: f32) {
let _ = self.commands.send(Command::SetVolume(volume));
}
}
pub fn spawn(on_event: impl Fn(Event) + Send + 'static) -> io::Result<Controller> {
let (commands, receiver) = std::sync::mpsc::channel();
let shared = Arc::new(Shared::default());
let thread_shared = Arc::clone(&shared);
std::thread::Builder::new()
.name("furumi-audio".into())
.spawn(move || run(&receiver, &thread_shared, &on_event))?;
Ok(Controller { commands, shared })
}
struct Output {
_device: MixerDeviceSink,
player: Player,
}
fn run(receiver: &Receiver<Command>, shared: &Arc<Shared>, on_event: &impl Fn(Event)) {
let mut output: Option<Output> = None;
let mut track_loaded = false;
let mut previous_queue_len = 0;
loop {
match receiver.recv_timeout(Duration::from_millis(50)) {
Ok(command) => {
handle(command, shared, &mut output, &mut track_loaded, on_event);
previous_queue_len = output.as_ref().map_or(0, |output| output.player.len());
}
Err(RecvTimeoutError::Timeout) => {
if let Some(output) = &output {
let millis =
u64::try_from(output.player.get_pos().as_millis()).unwrap_or(u64::MAX);
shared.position_ms.store(millis, Ordering::Relaxed);
let queue_len = output.player.len();
if track_loaded && queue_len < previous_queue_len {
track_loaded = false;
on_event(Event::Finished);
}
previous_queue_len = queue_len;
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
#[allow(clippy::too_many_lines, reason = "exhaustive audio command dispatcher")]
fn handle(
command: Command,
shared: &Arc<Shared>,
output: &mut Option<Output>,
track_loaded: &mut bool,
on_event: &impl Fn(Event),
) {
match command {
Command::Play { path, volume } => {
let output = match ensure_output(output) {
Ok(output) => output,
Err(error) => {
on_event(Event::Failed(format!("cannot open audio output: {error}")));
return;
}
};
let file = match File::open(&path) {
Ok(file) => file,
Err(error) => {
on_event(Event::Failed(format!(
"cannot open {}: {error}",
path.display()
)));
return;
}
};
let byte_len = file.metadata().ok().map(|metadata| metadata.len());
let mut decoder = Decoder::builder()
.with_data(file)
.with_seekable(true)
.with_gapless(true);
if let Some(byte_len) = byte_len {
decoder = decoder.with_byte_len(byte_len);
}
match decoder.build() {
Ok(source) => {
output.player.stop();
output.player.set_volume(amplitude(volume));
output.player.append(source);
output.player.play();
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = true;
on_event(Event::Started);
}
Err(error) => on_event(Event::Failed(format!(
"cannot decode {}: {error}",
path.display()
))),
}
}
Command::PlayStream {
reader,
mime_type,
volume,
} => {
let output = match ensure_output(output) {
Ok(output) => output,
Err(error) => {
on_event(Event::Failed(format!("cannot open audio output: {error}")));
return;
}
};
match Decoder::builder()
.with_data(reader)
.with_mime_type(&mime_type)
.with_seekable(false)
.with_gapless(true)
.build()
{
Ok(source) => {
output.player.stop();
output.player.set_volume(amplitude(volume));
output.player.append(source);
output.player.play();
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = true;
on_event(Event::Started);
}
Err(error) => on_event(Event::Failed(format!(
"cannot decode federated stream: {error}"
))),
}
}
Command::Pause => {
if let Some(output) = output {
output.player.pause();
}
}
Command::Resume => {
if let Some(output) = output {
output.player.play();
}
}
Command::Stop => {
if let Some(output) = output {
output.player.stop();
}
shared.position_ms.store(0, Ordering::Relaxed);
*track_loaded = false;
}
Command::Seek(position) => {
if let Some(output) = output
&& let Err(error) = output.player.try_seek(position)
{
on_event(Event::Failed(format!("cannot seek: {error}")));
}
}
Command::SetVolume(volume) => {
if let Some(output) = output {
output.player.set_volume(amplitude(volume));
}
}
}
}
fn ensure_output(output: &mut Option<Output>) -> Result<&Output, rodio::stream::DeviceSinkError> {
if output.is_none() {
let device = DeviceSinkBuilder::open_default_sink()?;
let player = Player::connect_new(device.mixer());
*output = Some(Output {
_device: device,
player,
});
}
Ok(output.as_ref().expect("audio output initialized above"))
}
fn amplitude(volume: f32) -> f32 {
volume.clamp(0.0, 1.0).powi(3)
}
#[cfg(test)]
mod tests {
use super::amplitude;
#[test]
fn perceptual_volume_is_clamped_and_cubic() {
assert!(amplitude(-1.0).abs() < f32::EPSILON);
assert!((amplitude(0.5) - 0.125).abs() < f32::EPSILON);
assert!((amplitude(2.0) - 1.0).abs() < f32::EPSILON);
}
}
File diff suppressed because it is too large Load Diff
+875
View File
@@ -0,0 +1,875 @@
use super::*;
impl DeviceSync {
pub(super) fn ensure_identity(&self) -> Result<Identity> {
let conn = lock(&self.conn);
if let (Some(device_id), Some(group_id), Some(name)) = (
get_meta(&conn, "device_id")?,
get_meta(&conn, "group_id")?,
get_meta(&conn, "device_name")?,
) {
return Ok(Identity {
device_id,
group_id,
name,
});
}
let seed = random_hex(32);
let digest = hash_secret(&seed);
let device_id = format!("dev_{}", &digest[..24]);
let group_id = format!("grp_{}", &hash_secret(&device_id)[..24]);
let name = format!("Furumi on {}", std::env::consts::OS);
set_meta(&conn, "device_id", &device_id)?;
set_meta(&conn, "device_secret", &seed)?;
set_meta(&conn, "group_id", &group_id)?;
set_meta(&conn, "device_name", &name)?;
set_meta(&conn, "local_seq", "0")?;
set_meta(&conn, "last_hlc_ms", "0")?;
conn.execute(
"INSERT INTO sync_devices
(device_id, name, client_version, protocol_version, trusted_at_ms, last_seen_ms)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
ON CONFLICT(device_id) DO UPDATE SET name = excluded.name,
client_version = excluded.client_version,
protocol_version = excluded.protocol_version,
trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms)",
params![
device_id,
name,
CLIENT_VERSION,
DEVICE_SYNC_PROTOCOL_VERSION,
now_ms()
],
)?;
Ok(Identity {
device_id,
group_id,
name,
})
}
pub(super) fn set_group_id(&self, group_id: &str) -> Result<()> {
let conn = lock(&self.conn);
set_meta(&conn, "group_id", group_id)
}
pub(super) fn set_meta(&self, key: &str, value: &str) -> Result<()> {
set_meta(&lock(&self.conn), key, value)
}
pub(super) fn own_profile(&self, ticket: &str) -> Result<DeviceProfileWire> {
let identity = self.ensure_identity()?;
Ok(DeviceProfileWire {
device_id: identity.device_id,
name: identity.name,
client_version: CLIENT_VERSION.into(),
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
endpoint_id: ticket_endpoint_id(ticket).unwrap_or_default(),
endpoint_ticket: ticket.into(),
revoked: false,
revoke_cutoff_seq: None,
updated_at_ms: now_ms(),
})
}
pub(super) fn active_device_count(&self) -> Result<usize> {
let count = lock(&self.conn).query_row(
"SELECT COUNT(*) FROM sync_devices
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL",
[],
|row| row.get::<_, i64>(0),
)?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
pub(super) fn status_inner(&self) -> Result<Status> {
let identity = self.ensure_identity()?;
let conn = lock(&self.conn);
let now = now_ms();
let devices = {
let mut stmt = conn.prepare(
"SELECT device_id, name, client_version, last_seen_ms
FROM sync_devices
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL
ORDER BY name COLLATE NOCASE, device_id",
)?;
stmt.query_map([], |row| {
let id: String = row.get(0)?;
let last_seen: Option<i64> = row.get(3)?;
Ok(DeviceRow {
is_self: id == identity.device_id,
online: id == identity.device_id
|| last_seen.is_some_and(|seen| now.saturating_sub(seen) <= ONLINE_TTL_MS),
id,
name: row.get(1)?,
client_version: row.get(2)?,
revoked: false,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
let pending = {
let mut stmt = conn.prepare(
"SELECT request_id, device_id, name, client_version,
requester_group_id, requester_group_active_devices
FROM sync_pending_pairing WHERE status = 'pending'
ORDER BY created_at_ms",
)?;
stmt.query_map([], |row| {
Ok(PendingPairing {
request_id: row.get(0)?,
device_id: row.get(1)?,
name: row.get(2)?,
client_version: row.get(3)?,
requester_group_id: row.get(4)?,
requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0))
.unwrap_or(usize::MAX),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
Ok(Status {
this_device_id: identity.device_id,
this_device_name: identity.name,
group_id: identity.group_id,
devices,
pending,
last_sync: get_meta(&conn, "last_sync")?,
error: get_meta(&conn, "last_error")?,
})
}
pub(super) fn device_profiles(&self) -> Result<Vec<DeviceProfileWire>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT device_id, name, client_version, protocol_version, endpoint_id,
endpoint_ticket, revoked_at_ms IS NOT NULL, revoke_cutoff_seq,
MAX(COALESCE(last_seen_ms, 0), COALESCE(trusted_at_ms, 0),
COALESCE(revoked_at_ms, 0))
FROM sync_devices WHERE trusted_at_ms IS NOT NULL",
)?;
Ok(stmt
.query_map([], |row| {
Ok(DeviceProfileWire {
device_id: row.get(0)?,
name: row.get(1)?,
client_version: row.get(2)?,
protocol_version: row.get::<_, u16>(3)?,
endpoint_id: row.get(4)?,
endpoint_ticket: row.get(5)?,
revoked: row.get::<_, i64>(6)? != 0,
revoke_cutoff_seq: row.get(7)?,
updated_at_ms: row.get(8)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub(super) fn apply_device_profiles(&self, profiles: &[DeviceProfileWire]) -> Result<()> {
for profile in profiles {
self.apply_device_profile(profile, false)?;
}
Ok(())
}
pub(super) fn apply_device_profile(
&self,
profile: &DeviceProfileWire,
trusted: bool,
) -> Result<()> {
if profile.device_id == self.ensure_identity()?.device_id {
return Ok(());
}
let now = now_ms();
lock(&self.conn).execute(
"INSERT INTO sync_devices
(device_id, name, client_version, protocol_version, endpoint_id,
endpoint_ticket, trusted_at_ms, last_seen_ms, revoked_at_ms,
revoke_cutoff_seq)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(device_id) DO UPDATE SET
name = CASE WHEN excluded.last_seen_ms >= COALESCE(sync_devices.last_seen_ms, 0)
THEN excluded.name ELSE sync_devices.name END,
client_version = excluded.client_version,
protocol_version = excluded.protocol_version,
endpoint_id = CASE WHEN excluded.endpoint_id != '' THEN excluded.endpoint_id
ELSE sync_devices.endpoint_id END,
endpoint_ticket = CASE WHEN excluded.endpoint_ticket != '' THEN excluded.endpoint_ticket
ELSE sync_devices.endpoint_ticket END,
trusted_at_ms = COALESCE(sync_devices.trusted_at_ms, excluded.trusted_at_ms),
last_seen_ms = MAX(COALESCE(sync_devices.last_seen_ms, 0), excluded.last_seen_ms),
revoked_at_ms = CASE WHEN excluded.revoked_at_ms IS NOT NULL
THEN excluded.revoked_at_ms ELSE sync_devices.revoked_at_ms END,
revoke_cutoff_seq = COALESCE(excluded.revoke_cutoff_seq,
sync_devices.revoke_cutoff_seq)",
params![
profile.device_id,
profile.name,
profile.client_version,
profile.protocol_version,
profile.endpoint_id,
profile.endpoint_ticket,
trusted.then_some(now),
profile.updated_at_ms.max(now),
profile.revoked.then_some(profile.updated_at_ms.max(now)),
profile.revoke_cutoff_seq,
],
)?;
Ok(())
}
pub(super) fn mark_seen(&self, device_id: &str, endpoint_id: &str) -> Result<()> {
lock(&self.conn).execute(
"UPDATE sync_devices SET last_seen_ms = ?2,
endpoint_id = CASE WHEN ?3 != '' THEN ?3 ELSE endpoint_id END
WHERE device_id = ?1",
params![device_id, now_ms(), endpoint_id],
)?;
Ok(())
}
pub(super) fn record_op(&self, payload: SyncOpPayload) -> Result<()> {
let op = {
let conn = lock(&self.conn);
let identity = Self::ensure_identity_with_conn(&conn)?;
let seq = get_meta(&conn, "local_seq")?
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0)
.saturating_add(1);
let previous_hlc = get_meta(&conn, "last_hlc_ms")?
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(0);
let hlc_ms = now_ms().max(previous_hlc.saturating_add(1));
set_meta(&conn, "local_seq", &seq.to_string())?;
set_meta(&conn, "last_hlc_ms", &hlc_ms.to_string())?;
SyncOpWire {
op_id: format!("{}:{seq}", identity.device_id),
origin_device_id: identity.device_id,
seq,
hlc_ms,
payload,
}
};
self.store_and_apply_op(&op)?;
self.request_sync();
self.notify();
Ok(())
}
pub(super) fn ensure_identity_with_conn(conn: &Connection) -> Result<Identity> {
Ok(Identity {
device_id: get_meta(conn, "device_id")?.context("missing device id")?,
group_id: get_meta(conn, "group_id")?.context("missing device group")?,
name: get_meta(conn, "device_name")?.context("missing device name")?,
})
}
pub(super) fn store_and_apply_op(&self, op: &SyncOpWire) -> Result<()> {
let inserted = lock(&self.conn).execute(
"INSERT OR IGNORE INTO sync_ops
(op_id, origin_device_id, seq, kind, payload_json, hlc_ms,
received_at_ms, tombstone)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
op.op_id,
op.origin_device_id,
op.seq,
payload_kind(&op.payload),
serde_json::to_string(&op.payload)?,
op.hlc_ms,
now_ms(),
i64::from(op.payload.is_tombstone()),
],
)?;
if inserted == 0 {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_vectors (device_id, max_seq) VALUES (?1, ?2)
ON CONFLICT(device_id) DO UPDATE SET max_seq = MAX(max_seq, excluded.max_seq)",
params![op.origin_device_id, op.seq],
)?;
self.apply_op(op)
}
pub(super) fn apply_ops(&self, ops: Vec<SyncOpWire>) -> Result<()> {
for op in ops {
if self.should_accept_op(&op)? {
self.store_and_apply_op(&op)?;
}
}
self.notify();
Ok(())
}
pub(super) fn should_accept_op(&self, op: &SyncOpWire) -> Result<bool> {
if op.origin_device_id == self.ensure_identity()?.device_id {
return Ok(true);
}
let conn = lock(&self.conn);
let row = conn
.query_row(
"SELECT trusted_at_ms IS NOT NULL, revoked_at_ms IS NOT NULL,
COALESCE(revoke_cutoff_seq, 0)
FROM sync_devices WHERE device_id = ?1",
[&op.origin_device_id],
|row| {
Ok((
row.get::<_, i64>(0)? != 0,
row.get::<_, i64>(1)? != 0,
row.get::<_, i64>(2)?,
))
},
)
.optional()?;
Ok(row.is_some_and(|(trusted, revoked, cutoff)| trusted && (!revoked || op.seq <= cutoff)))
}
pub(super) fn vector(&self) -> Result<BTreeMap<String, i64>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare("SELECT device_id, max_seq FROM sync_vectors")?;
Ok(stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<rusqlite::Result<BTreeMap<_, _>>>()?)
}
pub(super) fn ops_for_peer(&self, peer: &str) -> Result<Vec<SyncOpWire>> {
let conn = lock(&self.conn);
let mut stmt = conn.prepare(
"SELECT payload_json, op_id, origin_device_id, seq, hlc_ms
FROM sync_ops o
WHERE seq > COALESCE((SELECT max_seq FROM sync_peer_acks a
WHERE a.peer_device_id = ?1
AND a.origin_device_id = o.origin_device_id), 0)
AND (kind != 'playback_command' OR hlc_ms >= ?2)
ORDER BY received_at_ms, origin_device_id, seq LIMIT ?3",
)?;
Ok(stmt
.query_map(
params![
peer,
now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS),
i64::try_from(MAX_OPS_PER_BATCH).unwrap_or(i64::MAX)
],
|row| {
let payload: String = row.get(0)?;
Ok(SyncOpWire {
op_id: row.get(1)?,
origin_device_id: row.get(2)?,
seq: row.get(3)?,
hlc_ms: row.get(4)?,
payload: serde_json::from_str(&payload).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
0,
rusqlite::types::Type::Text,
Box::new(error),
)
})?,
})
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub(super) fn note_peer_vector(
&self,
peer: &str,
vector: &BTreeMap<String, i64>,
) -> Result<()> {
let conn = lock(&self.conn);
for (origin, seq) in vector {
conn.execute(
"INSERT INTO sync_peer_acks
(peer_device_id, origin_device_id, max_seq, updated_at_ms)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(peer_device_id, origin_device_id) DO UPDATE SET
max_seq = MAX(max_seq, excluded.max_seq),
updated_at_ms = excluded.updated_at_ms",
params![peer, origin, seq, now_ms()],
)?;
}
Ok(())
}
}
impl DeviceSync {
pub(super) fn apply_op(&self, op: &SyncOpWire) -> Result<()> {
match &op.payload {
SyncOpPayload::TrackLikeSet {
content_id,
liked,
fed,
} => self.apply_like(content_id, *liked, fed.as_ref(), op.hlc_ms, &op.op_id)?,
SyncOpPayload::PlaylistCreated { playlist_id, title }
| SyncOpPayload::PlaylistRenamed { playlist_id, title } => {
self.apply_playlist(playlist_id, title, false, op.hlc_ms, &op.op_id)?;
}
SyncOpPayload::PlaylistDeleted { playlist_id } => {
self.apply_playlist(playlist_id, "", true, op.hlc_ms, &op.op_id)?;
}
SyncOpPayload::PlaylistTrackAdded {
playlist_id,
content_id,
position,
fed,
} => self.apply_playlist_item(
playlist_id,
content_id,
true,
*position,
fed.as_ref(),
op.hlc_ms,
&op.op_id,
)?,
SyncOpPayload::PlaylistTrackRemoved {
playlist_id,
content_id,
} => self.apply_playlist_item(
playlist_id,
content_id,
false,
0,
None,
op.hlc_ms,
&op.op_id,
)?,
SyncOpPayload::DeviceProfileSet {
name,
client_version,
endpoint_ticket,
endpoint_id,
} => self.apply_device_profile(
&DeviceProfileWire {
device_id: op.origin_device_id.clone(),
name: name.clone(),
client_version: client_version.clone(),
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
endpoint_id: endpoint_id.clone(),
endpoint_ticket: endpoint_ticket.clone(),
revoked: false,
revoke_cutoff_seq: None,
updated_at_ms: op.hlc_ms,
},
false,
)?,
SyncOpPayload::DeviceTrusted { target_device_id } => {
lock(&self.conn).execute(
"UPDATE sync_devices SET trusted_at_ms = MAX(COALESCE(trusted_at_ms, 0), ?2),
revoked_at_ms = CASE WHEN COALESCE(revoked_at_ms, 0) <= ?2
THEN NULL ELSE revoked_at_ms END
WHERE device_id = ?1",
params![target_device_id, op.hlc_ms],
)?;
}
SyncOpPayload::DeviceRevoked {
target_device_id,
target_max_seq_seen,
} => {
lock(&self.conn).execute(
"UPDATE sync_devices SET revoked_at_ms = ?2, revoked_by = ?3,
revoke_cutoff_seq = ?4 WHERE device_id = ?1",
params![
target_device_id,
op.hlc_ms,
op.origin_device_id,
target_max_seq_seen
],
)?;
}
SyncOpPayload::PlaybackCommand {
target_device_id,
command,
} => self.apply_playback_command(target_device_id, command, &op.op_id)?,
SyncOpPayload::ListenRecorded { event } => {
self.library
.apply_listen_event(event, &op.origin_device_id)?;
}
}
Ok(())
}
pub(super) fn apply_like(
&self,
content_id: &str,
liked: bool,
fed: Option<&SyncedFedTrack>,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
return Ok(());
};
if !self.lww_wins("sync_state_likes", "content_id", &content_id, hlc_ms, op_id)? {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_likes (content_id, liked, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(content_id) DO UPDATE SET liked = excluded.liked,
hlc_ms = excluded.hlc_ms, op_id = excluded.op_id",
params![content_id, i64::from(liked), hlc_ms, op_id],
)?;
if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? {
self.library.set_synced_like(track_id, liked, hlc_ms)?;
if liked {
self.library.remove_fed_like_by_content_id(&content_id)?;
}
} else if liked {
if let Some(fed) = fed {
self.library
.upsert_synced_fed_like(&to_library_fed(fed), hlc_ms)?;
}
} else {
self.library.remove_fed_like_by_content_id(&content_id)?;
}
self.notify_library();
Ok(())
}
pub(super) fn apply_playlist(
&self,
playlist_id: &str,
title: &str,
deleted: bool,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
if !self.lww_wins(
"sync_state_playlists",
"playlist_id",
playlist_id,
hlc_ms,
op_id,
)? {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_playlists
(playlist_id, title, deleted, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(playlist_id) DO UPDATE SET title = excluded.title,
deleted = excluded.deleted, hlc_ms = excluded.hlc_ms,
op_id = excluded.op_id",
params![playlist_id, title, i64::from(deleted), hlc_ms, op_id],
)?;
if deleted {
self.library.delete_playlist_by_sync_id(playlist_id)?;
} else if !title.trim().is_empty() {
self.library.upsert_synced_playlist(playlist_id, title)?;
}
self.notify_library();
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn apply_playlist_item(
&self,
playlist_id: &str,
content_id: &str,
present: bool,
position: i64,
fed: Option<&SyncedFedTrack>,
hlc_ms: i64,
op_id: &str,
) -> Result<()> {
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
return Ok(());
};
let current = lock(&self.conn)
.query_row(
"SELECT hlc_ms, op_id FROM sync_state_playlist_items
WHERE playlist_id = ?1 AND content_id = ?2",
params![playlist_id, content_id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
)
.optional()?;
if current.is_some_and(|(current_hlc, current_op)| {
(current_hlc, current_op.as_str()) >= (hlc_ms, op_id)
}) {
return Ok(());
}
lock(&self.conn).execute(
"INSERT INTO sync_state_playlist_items
(playlist_id, content_id, present, position, hlc_ms, op_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(playlist_id, content_id) DO UPDATE SET
present = excluded.present, position = excluded.position,
hlc_ms = excluded.hlc_ms, op_id = excluded.op_id",
params![
playlist_id,
content_id,
i64::from(present),
position,
hlc_ms,
op_id
],
)?;
if present {
self.library
.add_content_id_to_synced_playlist(playlist_id, &content_id)?;
if let Some(fed) = fed {
self.library.upsert_fed_playlist_track(
playlist_id,
&to_library_fed(fed),
position,
)?;
} else if let Some(fed) = self.library.fed_like_by_content_id(&content_id)? {
self.library
.upsert_fed_playlist_track(playlist_id, &fed, position)?;
}
} else {
self.library
.remove_content_id_from_synced_playlist(playlist_id, &content_id)?;
}
self.notify_library();
Ok(())
}
pub(super) fn lww_wins(
&self,
table: &str,
key_name: &str,
key: &str,
hlc_ms: i64,
op_id: &str,
) -> Result<bool> {
let sql = format!("SELECT hlc_ms, op_id FROM {table} WHERE {key_name} = ?1");
let current = lock(&self.conn)
.query_row(&sql, [key], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})
.optional()?;
Ok(current.is_none_or(|(current_hlc, current_op)| {
(hlc_ms, op_id) > (current_hlc, current_op.as_str())
}))
}
pub(super) fn apply_playback_command(
&self,
target_device_id: &str,
command: &PlaybackCommand,
op_id: &str,
) -> Result<()> {
if target_device_id != self.ensure_identity()?.device_id {
return Ok(());
}
let inserted = lock(&self.conn).execute(
"INSERT OR IGNORE INTO sync_playback_applied (op_id, applied_at_ms)
VALUES (?1, ?2)",
params![op_id, now_ms()],
)?;
if inserted > 0 {
let _ = self
.events
.try_send(InternalEvent::DevicePlaybackCommand(command.clone()));
}
Ok(())
}
pub(super) fn apply_playback_snapshot(&self, snapshot: PlaybackSnapshot) {
if self
.ensure_identity()
.is_ok_and(|identity| identity.device_id == snapshot.device_id)
{
return;
}
let changed = {
let mut playback = lock(&self.playback);
let changed = playback
.remote
.get(&snapshot.device_id)
.is_none_or(|current| snapshot.updated_at_ms > current.updated_at_ms);
if changed {
playback
.remote
.insert(snapshot.device_id.clone(), snapshot.clone());
}
changed
};
if changed {
let _ = self
.events
.try_send(InternalEvent::DevicePlaybackSnapshot(snapshot));
}
}
#[allow(
clippy::too_many_lines,
reason = "the snapshot is one transactional projection of all synchronized entities"
)]
pub(super) fn snapshot(&self) -> Result<SyncSnapshot> {
let conn = lock(&self.conn);
let mut snapshot = SyncSnapshot::default();
{
let mut stmt =
conn.prepare("SELECT content_id, liked, hlc_ms, op_id FROM sync_state_likes")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? != 0,
row.get::<_, i64>(2)?,
row.get::<_, String>(3)?,
))
})?;
for row in rows {
let (content_id, liked, hlc_ms, op_id) = row?;
if liked {
snapshot.likes.push(SnapshotLike {
content_id,
hlc_ms,
op_id,
fed: None,
});
} else {
snapshot.unlikes.push(SnapshotLikeTombstone {
content_id,
hlc_ms,
op_id,
});
}
}
}
let mut playlists = BTreeMap::<String, SnapshotPlaylist>::new();
{
let mut stmt = conn.prepare(
"SELECT playlist_id, title, deleted, hlc_ms, op_id
FROM sync_state_playlists",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, i64>(3)?,
row.get::<_, String>(4)?,
))
})?;
for row in rows {
let (playlist_id, title, deleted, hlc_ms, op_id) = row?;
if deleted {
snapshot.deleted_playlists.push(SnapshotPlaylistTombstone {
playlist_id,
hlc_ms,
op_id,
});
} else {
playlists.insert(
playlist_id.clone(),
SnapshotPlaylist {
playlist_id,
title,
hlc_ms,
op_id,
items: Vec::new(),
},
);
}
}
}
{
let mut stmt = conn.prepare(
"SELECT playlist_id, content_id, present, position, hlc_ms, op_id
FROM sync_state_playlist_items",
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)? != 0,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, String>(5)?,
))
})?;
for row in rows {
let (playlist_id, content_id, present, position, hlc_ms, op_id) = row?;
if present {
if let Some(playlist) = playlists.get_mut(&playlist_id) {
playlist.items.push(SnapshotPlaylistItem {
content_id,
position,
hlc_ms,
op_id,
fed: None,
});
}
} else {
snapshot
.removed_playlist_items
.push(SnapshotPlaylistItemTombstone {
playlist_id,
content_id,
hlc_ms,
op_id,
});
}
}
}
snapshot.playlists = playlists.into_values().collect();
Ok(snapshot)
}
pub(super) fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> {
for like in snapshot.likes {
self.apply_like(
&like.content_id,
true,
like.fed.as_ref(),
like.hlc_ms,
&like.op_id,
)?;
}
for like in snapshot.unlikes {
self.apply_like(&like.content_id, false, None, like.hlc_ms, &like.op_id)?;
}
for playlist in snapshot.playlists {
self.apply_playlist(
&playlist.playlist_id,
&playlist.title,
false,
playlist.hlc_ms,
&playlist.op_id,
)?;
for item in playlist.items {
self.apply_playlist_item(
&playlist.playlist_id,
&item.content_id,
true,
item.position,
item.fed.as_ref(),
item.hlc_ms,
&item.op_id,
)?;
}
}
for playlist in snapshot.deleted_playlists {
self.apply_playlist(
&playlist.playlist_id,
"",
true,
playlist.hlc_ms,
&playlist.op_id,
)?;
}
for item in snapshot.removed_playlist_items {
self.apply_playlist_item(
&item.playlist_id,
&item.content_id,
false,
0,
None,
item.hlc_ms,
&item.op_id,
)?;
}
self.notify();
Ok(())
}
pub(super) fn notify(&self) {
let _ = self.events.try_send(InternalEvent::DevicesChanged);
}
pub(super) fn notify_library(&self) {
let _ = self.events.try_send(InternalEvent::DeviceLibraryChanged);
}
}
+858
View File
@@ -0,0 +1,858 @@
//! Federation lifecycle, DHT search and catalog artwork fetching.
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use furumi_backend_api::{FederationDebugSnapshot, SearchResults, SearchStats};
use furumi_domain::{
Artist, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, Release,
ReleaseKey, Track, TrackKey,
};
use music_dht::catalog::{
CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse,
};
use music_dht::{
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
RendezvousConfig,
};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt as _};
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
const STREAM_BUFFER: u64 = 2 * 1024 * 1024;
#[derive(Serialize)]
struct AudioRequest {
item_id: String,
offset: u64,
want_cover: bool,
metadata_only: bool,
}
#[derive(Deserialize)]
struct AudioHeader {
ok: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
mime_type: String,
#[serde(default)]
total_size: u64,
#[serde(default)]
cover_size: u64,
#[serde(default)]
artist_image_size: u64,
#[serde(default)]
metadata: Option<TrackMetadata>,
}
#[derive(Clone, Default, Deserialize)]
pub struct TrackMetadata {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub album_artists: Vec<String>,
#[serde(default)]
pub release_title: String,
#[serde(default)]
pub release_type: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: Option<f64>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
}
pub enum StreamEvent {
Ready(crate::streaming::GrowingFileReader, String),
Complete(PathBuf, Box<Option<TrackMetadata>>),
Failed(String),
}
const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
pub struct Client {
service: Arc<MusicDhtService>,
media_dir: PathBuf,
}
impl Client {
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&data_dir).await?;
tokio::fs::create_dir_all(&media_dir).await?;
let config = MusicDhtConfig::builder()
.data_dir(data_dir)
.network_id(NetworkId::from_name(network))
.rendezvous(RendezvousConfig::default())
.stream_protocol(CATALOG_ALPN)
.stream_protocol(AUDIO_ALPN)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V1)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V2)
.build()
.context("invalid federation configuration")?;
let (service, mut events) = MusicDhtService::start(config)
.await
.context("starting federation node")?;
tokio::spawn(async move { while events.recv().await.is_some() {} });
Ok(Arc::new(Self {
service: Arc::new(service),
media_dir,
}))
}
pub fn service(&self) -> Arc<MusicDhtService> {
Arc::clone(&self.service)
}
pub async fn debug_snapshot(&self) -> FederationDebugSnapshot {
let stored_dht_records = self.service.dht_record_count().await.ok();
let published_items = self
.service
.list_local_items()
.await
.map_or(0, |items| items.len());
FederationDebugSnapshot {
running: true,
endpoint_id: self.service.endpoint_id().to_string(),
dht_node_id: self.service.node_id().to_string(),
connected_peers: self.service.connected_peers().len(),
known_contacts: self.service.known_peers().len(),
stored_dht_records,
published_items,
error: None,
}
}
/// Resolves album artwork for a queue item independently of the screen
/// from which the track was enqueued.
pub async fn artwork_for_track(&self, track: &Track) -> Option<PathBuf> {
let peer_id = match &track.audio_source {
AudioSource::Federation { peer_id, .. } if !peer_id.is_empty() => peer_id.as_str(),
_ => track.key.federation_id()?.0,
};
let owner = peer_id.parse::<EndpointId>().ok()?;
let artist = track
.artists
.first()
.map(|artist| artist.name.as_str())
.or_else(|| (!track.artist.is_empty()).then_some(track.artist.as_str()))?;
if track.release.is_empty() {
return None;
}
self.cached_image(
owner,
artist,
Some(&track.release),
&format!("release-{peer_id}-{artist}-{}", track.release),
)
.await
}
pub async fn search(&self, query: &str) -> Result<(SearchResults, SearchStats)> {
let outcome = self.service.search_network(query).await?;
let own = self.service.endpoint_id();
let mut results = convert_items(&outcome.network_results, own);
self.fetch_artwork(&mut results).await;
let stats = SearchStats {
tracks: results.tracks.len(),
artists: results.artists.len(),
peers_queried: outcome.queried_nodes,
duration_ms: u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX),
};
Ok((results, stats))
}
/// Resolves a portable queue entry when another connected device only
/// knows its stable audio content id.
pub async fn track_by_content_id(&self, content_id: &str) -> Result<Track> {
let outcome = self.service.search_content_id(content_id).await?;
let own = self.service.endpoint_id();
let items = outcome
.local_results
.into_iter()
.chain(outcome.network_results)
.collect::<Vec<_>>();
convert_items(&items, own)
.tracks
.into_iter()
.next()
.context("no federation peer currently publishes this track")
}
pub async fn publish(&self, specs: Vec<ItemSpec>) -> Result<()> {
self.service.sync_library(specs).await?;
Ok(())
}
pub async fn artist_card(&self, name: &str) -> Result<(SearchResults, SearchStats)> {
let started = std::time::Instant::now();
let outcome = self.service.search_network(name).await?;
let own = self.service.endpoint_id();
let normalized = music_dht::normalize_name(name);
let owners = outcome
.network_results
.iter()
.filter(|item| {
item.owner != own
&& ((item.kind == ItemKind::Artist && item.normalized_name == normalized)
|| item
.artist_names
.iter()
.chain(item.featured_artist_names.iter())
.any(|artist| music_dht::normalize_name(artist) == normalized))
})
.map(|item| item.owner)
.collect::<HashSet<_>>();
let mut catalogs = Vec::new();
for owner in owners {
if let Ok(Ok(catalog)) = tokio::time::timeout(
std::time::Duration::from_secs(5),
fetch_catalog(&self.service, owner, name),
)
.await
{
catalogs.push((owner.to_string(), catalog));
}
}
let mut result = card_results(name, catalogs);
self.fetch_artwork(&mut result).await;
let stats = SearchStats {
tracks: result.tracks.len(),
artists: result.artists.len(),
peers_queried: outcome.queried_nodes,
duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
};
Ok((result, stats))
}
pub async fn stream_track(
&self,
peer: &str,
item_id: &str,
directory: &std::path::Path,
stem: &str,
events: tokio::sync::mpsc::Sender<StreamEvent>,
) -> Result<()> {
use tokio::io::AsyncWriteExt as _;
let owner: EndpointId = peer.parse().context("invalid peer id")?;
tokio::fs::create_dir_all(directory).await?;
let mut stream = self.service.open_stream(owner, AUDIO_ALPN).await?;
let mut request = serde_json::to_vec(&AudioRequest {
item_id: item_id.into(),
offset: 0,
want_cover: false,
metadata_only: false,
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
anyhow::ensure!(
header.ok,
"{}",
header.error.unwrap_or_else(|| "peer refused audio".into())
);
anyhow::ensure!(
header.cover_size == 0 && header.artist_image_size == 0,
"unexpected image data in audio stream"
);
let extension = match header.mime_type.as_str() {
"audio/mpeg" => "mp3",
"audio/flac" | "audio/x-flac" => "flac",
"audio/ogg" => "ogg",
"audio/opus" => "opus",
"audio/wav" | "audio/x-wav" => "wav",
"audio/mp4" | "audio/x-m4a" => "m4a",
"audio/aac" => "aac",
_ => "bin",
};
let final_path = directory.join(format!("{stem}.{extension}"));
let part_path = directory.join(format!(".{stem}.{extension}.part"));
let mut file = tokio::fs::File::create(&part_path).await?;
let (reader, writer) = crate::streaming::growing_file(&part_path)?;
let mut reader = Some(reader);
let mut started = false;
let mut received = 0u64;
let threshold = header.total_size.clamp(1, STREAM_BUFFER);
let mut chunk = vec![0; 64 * 1024];
while let Some(n) = stream.recv.read(&mut chunk).await? {
file.write_all(&chunk[..n]).await?;
received += n as u64;
writer.add_available(n as u64);
if !started
&& received >= threshold
&& let Some(reader) = reader.take()
{
let _ = events
.send(StreamEvent::Ready(reader, header.mime_type.clone()))
.await;
started = true;
}
}
if !started
&& received > 0
&& let Some(reader) = reader.take()
{
let _ = events
.send(StreamEvent::Ready(reader, header.mime_type.clone()))
.await;
}
writer.finish();
file.flush().await?;
drop(file);
anyhow::ensure!(
header.total_size == 0 || received == header.total_size,
"incomplete audio download: {received}/{}",
header.total_size
);
tokio::fs::rename(&part_path, &final_path).await?;
let _ = events
.send(StreamEvent::Complete(final_path, Box::new(header.metadata)))
.await;
Ok(())
}
async fn fetch_artwork(&self, results: &mut SearchResults) {
for artist in &mut results.artists {
let CatalogSource::Federation { peer_id } = &artist.source else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
None,
&format!("artist-{peer_id}-{}", artist.name),
)
.await
{
artist.artwork.uri = Some(path.to_string_lossy().into_owned());
}
}
for release in &mut results.releases {
let CatalogSource::Federation { peer_id } = &release.source else {
continue;
};
let Some(artist) = release.artists.first() else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
Some(&release.title),
&format!("release-{peer_id}-{}-{}", artist.name, release.title),
)
.await
{
let uri = path.to_string_lossy().into_owned();
release.artwork.uri = Some(uri.clone());
for track in &mut release.tracks {
if track.cover_uri.is_none() {
track.cover_uri = Some(uri.clone());
}
}
for track in &mut results.tracks {
if track.release == release.title
&& track
.artists
.first()
.is_some_and(|candidate| candidate.name == artist.name)
{
track.cover_uri = Some(uri.clone());
}
}
}
}
for track in &mut results.tracks {
if track.cover_uri.is_some() || track.release.is_empty() {
continue;
}
let AudioSource::Federation { peer_id, .. } = &track.audio_source else {
continue;
};
let Some(artist) = track.artists.first() else {
continue;
};
let Ok(owner) = peer_id.parse::<EndpointId>() else {
continue;
};
if let Some(path) = self
.cached_image(
owner,
&artist.name,
Some(&track.release),
&format!("release-{peer_id}-{}-{}", artist.name, track.release),
)
.await
{
track.cover_uri = Some(path.to_string_lossy().into_owned());
}
}
}
async fn cached_image(
&self,
owner: EndpointId,
artist: &str,
release: Option<&str>,
cache_key: &str,
) -> Option<PathBuf> {
let mut hasher = DefaultHasher::new();
cache_key.hash(&mut hasher);
let base = self.media_dir.join(format!("{:016x}", hasher.finish()));
for extension in ["jpg", "png", "webp", "gif", "bmp"] {
let path = base.with_extension(extension);
if path.is_file() {
return Some(path);
}
}
let fetched = tokio::time::timeout(
IMAGE_TIMEOUT,
fetch_image(&self.service, owner, artist, release),
)
.await
.ok()?
.ok()?;
let (bytes, extension) = fetched?;
let path = base.with_extension(extension);
tokio::fs::write(&path, bytes).await.ok()?;
Some(path)
}
}
async fn fetch_catalog(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
) -> Result<CatalogArtist> {
let mut stream = service.open_stream(owner, CATALOG_ALPN).await?;
let mut request = serde_json::to_vec(&CatalogRequest {
artist: artist.into(),
..CatalogRequest::default()
})?;
request.push(b'\n');
stream.send.write_all(&request).await?;
stream.send.finish()?;
let mut payload = Vec::new();
stream
.recv
.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut payload)
.await?;
anyhow::ensure!(
payload.len() <= 4 * 1024 * 1024,
"catalog response is too large"
);
let response: CatalogResponse = serde_json::from_slice(&payload)?;
anyhow::ensure!(
response.ok,
"{}",
response.error.unwrap_or_else(|| "catalog rejected".into())
);
response.artist.context("empty artist catalog")
}
#[allow(
clippy::too_many_lines,
reason = "wire catalog conversion is clearest as one pass"
)]
fn card_results(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> SearchResults {
let mut results = SearchResults::default();
if let Some((peer, _)) = catalogs.first() {
results.artists.push(Artist {
key: ArtistKey::Federation {
peer_id: peer.clone(),
id: music_dht::normalize_name(name),
},
source: CatalogSource::Federation {
peer_id: peer.clone(),
},
name: name.into(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
});
}
let mut release_slots = HashMap::<String, usize>::new();
for (peer, catalog) in catalogs {
let artist_key = ArtistKey::Federation {
peer_id: peer.clone(),
id: music_dht::normalize_name(name),
};
for remote in catalog.releases {
let normalized = music_dht::normalize_name(&remote.title);
let slot = *release_slots.entry(normalized.clone()).or_insert_with(|| {
results.releases.push(Release {
key: ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!("name:{normalized}"),
},
source: CatalogSource::Federation {
peer_id: peer.clone(),
},
title: remote.title.clone(),
artists: vec![ArtistRef {
key: artist_key.clone(),
name: name.into(),
}],
featured_artists: Vec::new(),
release_type: remote.release_type.clone(),
year: remote.year,
artwork: Artwork::default(),
tracks: Vec::new(),
});
results.releases.len() - 1
});
let release = &mut results.releases[slot];
for item in remote.tracks {
let duplicate = release.tracks.iter().any(|track| {
music_dht::normalize_name(&track.title)
== music_dht::normalize_name(&item.title)
});
if duplicate || item.item_id.is_empty() {
continue;
}
let content_id = item
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok());
let artists = artist_refs(&peer, &item.artists);
let featured = artist_refs(&peer, &item.featured_artists);
let track = Track {
key: TrackKey::federation(
peer.clone(),
item.item_id.clone(),
content_id.clone(),
),
title: item.title,
artist: artist_line(&item.artists, &item.featured_artists),
artists,
featured_artists: featured,
release: release.title.clone(),
release_id: release.key.clone(),
duration_seconds: item.duration_seconds.unwrap_or_default(),
track_number: item
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: release.artwork.uri.clone(),
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer.clone(),
content_id: content_id.unwrap_or_else(|| {
ContentId::parse(format!("b3:{}", "0".repeat(64)))
.expect("valid fallback")
}),
},
};
release.tracks.push(track.clone());
results.tracks.push(track);
}
}
for appearance in catalog.appears_on {
if appearance.track.item_id.is_empty() {
continue;
}
let content_id = appearance
.track
.content_id
.as_deref()
.and_then(|id| ContentId::parse(id).ok());
let release_key = ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!(
"name:{}",
music_dht::normalize_name(&appearance.release_title)
),
};
results.tracks.push(Track {
key: TrackKey::federation(
peer.clone(),
appearance.track.item_id.clone(),
content_id.clone(),
),
title: appearance.track.title,
artist: artist_line(
&appearance.track.artists,
&appearance.track.featured_artists,
),
artists: artist_refs(&peer, &appearance.track.artists),
featured_artists: artist_refs(&peer, &appearance.track.featured_artists),
release: appearance.release_title,
release_id: release_key,
duration_seconds: appearance.track.duration_seconds.unwrap_or_default(),
track_number: appearance
.track
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: appearance
.track
.disc_number
.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer.clone(),
content_id: content_id.unwrap_or_else(|| {
ContentId::parse(format!("b3:{}", "0".repeat(64))).expect("valid fallback")
}),
},
});
}
}
for release in &mut results.releases {
populate_release_contributors(release);
}
if let Some(artist) = results.artists.first_mut() {
artist.release_count = results.releases.len();
artist.track_count = results.tracks.len();
}
results
}
fn populate_release_contributors(release: &mut Release) {
let mut known = release
.artists
.iter()
.chain(release.featured_artists.iter())
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
for artist in release
.tracks
.iter()
.flat_map(|track| track.artists.iter().chain(track.featured_artists.iter()))
{
if known.insert(music_dht::normalize_name(&artist.name)) {
release.featured_artists.push(artist.clone());
}
}
}
fn convert_items(items: &[LibraryItem], own: EndpointId) -> SearchResults {
let mut results = SearchResults::default();
let mut artists = HashMap::<(String, String), Artist>::new();
let mut releases = HashSet::<(String, String)>::new();
let mut tracks = HashSet::<(String, String)>::new();
for item in items.iter().filter(|item| item.owner != own) {
let peer = item.owner.to_string();
let source = CatalogSource::Federation {
peer_id: peer.clone(),
};
let refs = artist_refs(&peer, &item.artist_names);
let featured = artist_refs(&peer, &item.featured_artist_names);
for name in item
.artist_names
.iter()
.chain(item.featured_artist_names.iter())
{
note_artist(&mut artists, &peer, name);
}
match item.kind {
ItemKind::Artist => {
note_artist(&mut artists, &peer, &item.name);
}
ItemKind::Release => {
if releases.insert((peer.clone(), item.id.to_string())) {
results.releases.push(Release {
key: ReleaseKey::Federation {
peer_id: peer.clone(),
id: item.id.to_string(),
},
source,
title: item.name.clone(),
artists: refs,
featured_artists: Vec::new(),
release_type: item
.release_type
.clone()
.unwrap_or_else(|| "release".into()),
year: item.year,
artwork: Artwork::default(),
tracks: Vec::new(),
});
}
}
ItemKind::Track => {
if !tracks.insert((peer.clone(), item.id.to_string())) {
continue;
}
let Some(content_id) = item
.content_id
.as_deref()
.and_then(|value| ContentId::parse(value).ok())
else {
continue;
};
let release = item.release_title.clone().unwrap_or_default();
results.tracks.push(Track {
key: TrackKey::federation(
peer.clone(),
item.id.to_string(),
Some(content_id.clone()),
),
title: item.name.clone(),
artist: artist_line(&item.artist_names, &item.featured_artist_names),
artists: refs.clone(),
featured_artists: featured,
release: release.clone(),
release_id: ReleaseKey::Federation {
peer_id: peer.clone(),
id: format!("name:{}", music_dht::normalize_name(&release)),
},
duration_seconds: item.duration_seconds.unwrap_or_default(),
track_number: item
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: None,
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source: AudioSource::Federation {
peer_id: peer,
content_id,
},
});
}
}
}
results.artists = artists.into_values().collect();
results
.artists
.sort_by(|left, right| left.name.cmp(&right.name));
results
}
fn note_artist(artists: &mut HashMap<(String, String), Artist>, peer: &str, name: &str) {
let normalized = music_dht::normalize_name(name);
if normalized.is_empty() {
return;
}
artists
.entry((peer.to_owned(), normalized.clone()))
.or_insert_with(|| Artist {
key: ArtistKey::Federation {
peer_id: peer.to_owned(),
id: normalized,
},
source: CatalogSource::Federation {
peer_id: peer.to_owned(),
},
name: name.to_owned(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
});
}
fn artist_refs(peer: &str, names: &[String]) -> Vec<ArtistRef> {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer.to_owned(),
id: music_dht::normalize_name(name),
},
name: name.clone(),
})
.collect()
}
fn artist_line(main: &[String], featured: &[String]) -> String {
let mut line = main.join(", ");
if !featured.is_empty() {
if !line.is_empty() {
line.push_str(" feat. ");
}
line.push_str(&featured.join(", "));
}
line
}
async fn fetch_image(
service: &MusicDhtService,
owner: EndpointId,
artist: &str,
release: Option<&str>,
) -> Result<Option<(Vec<u8>, &'static str)>> {
let mut stream = service.open_stream(owner, CATALOG_ALPN).await?;
let request = CatalogRequest {
artist: artist.to_owned(),
want: Some(
if release.is_some() {
"release_cover"
} else {
"artist_image"
}
.into(),
),
release: release.map(str::to_owned),
cursor: None,
limit: None,
};
let mut line = serde_json::to_vec(&request)?;
line.push(b'\n');
stream.send.write_all(&line).await?;
stream.send.finish()?;
let header: CatalogImageHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
if !header.ok || header.size == 0 {
return Ok(None);
}
anyhow::ensure!(
header.size <= MAX_IMAGE_BYTES,
"federated image exceeds size limit"
);
let image_size =
usize::try_from(header.size).context("image is too large for this platform")?;
let mut bytes = vec![0; image_size];
stream.recv.read_exact(&mut bytes).await?;
let extension = match header.mime_type.as_str() {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
};
Ok(Some((bytes, extension)))
}
async fn read_line(reader: &mut (impl AsyncRead + Unpin)) -> Result<Vec<u8>> {
let mut line = Vec::new();
loop {
let byte = reader.read_u8().await?;
if byte == b'\n' {
return Ok(line);
}
anyhow::ensure!(line.len() < 64 * 1024, "catalog header is too large");
line.push(byte);
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
use std::fs;
use std::path::Path;
use furumi_backend_api::SettingsSnapshot;
use rusqlite::{Connection, OptionalExtension, params};
const MIGRATIONS: &[(i64, &str)] = &[
(
1,
r"
CREATE TABLE app_settings (
singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
network_id TEXT NOT NULL,
library_path TEXT NOT NULL,
federation_enabled INTEGER NOT NULL CHECK (federation_enabled IN (0, 1)),
language TEXT NOT NULL
);
INSERT INTO app_settings (
singleton_id, network_id, library_path, federation_enabled, language
) VALUES (1, 'furumi', '~/Music/Furumi', 1, 'English');
",
),
(
2,
"ALTER TABLE app_settings ADD COLUMN save_federated_on_listen INTEGER NOT NULL DEFAULT 1 CHECK (save_federated_on_listen IN (0, 1));",
),
(
3,
"ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';",
),
];
pub struct SettingsStore {
connection: Connection,
}
impl SettingsStore {
pub fn open(path: &Path) -> rusqlite::Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
}
let connection = Connection::open(path)?;
let mut store = Self { connection };
store.migrate()?;
Ok(store)
}
#[cfg(test)]
fn in_memory() -> rusqlite::Result<Self> {
let connection = Connection::open_in_memory()?;
let mut store = Self { connection };
store.migrate()?;
Ok(store)
}
pub fn load(&self) -> rusqlite::Result<SettingsSnapshot> {
self.connection.query_row(
"SELECT network_id, library_path, federation_enabled, language,
save_federated_on_listen, device_name
FROM app_settings WHERE singleton_id = 1",
[],
|row| {
Ok(SettingsSnapshot {
network_id: row.get(0)?,
library_path: row.get(1)?,
federation_enabled: row.get::<_, i64>(2)? != 0,
language: row.get(3)?,
save_federated_on_listen: row.get::<_, i64>(4)? != 0,
device_name: row.get(5)?,
})
},
)
}
pub fn save(&self, settings: &SettingsSnapshot) -> rusqlite::Result<()> {
self.connection.execute(
"UPDATE app_settings
SET network_id = ?1,
library_path = ?2,
federation_enabled = ?3,
language = ?4,
save_federated_on_listen = ?5,
device_name = ?6
WHERE singleton_id = 1",
params![
settings.network_id,
settings.library_path,
i64::from(settings.federation_enabled),
settings.language,
i64::from(settings.save_federated_on_listen),
settings.device_name,
],
)?;
Ok(())
}
fn migrate(&mut self) -> rusqlite::Result<()> {
self.connection.execute_batch(
"PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);",
)?;
for &(version, sql) in MIGRATIONS {
let applied = self
.connection
.query_row(
"SELECT 1 FROM schema_migrations WHERE version = ?1",
[version],
|_| Ok(()),
)
.optional()?
.is_some();
if applied {
continue;
}
let transaction = self.connection.transaction()?;
transaction.execute_batch(sql)?;
transaction.execute(
"INSERT INTO schema_migrations (version) VALUES (?1)",
[version],
)?;
transaction.commit()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migration_creates_defaults_and_settings_round_trip() {
let store = SettingsStore::in_memory().unwrap();
let mut settings = store.load().unwrap();
assert_eq!(settings.network_id, "furumi");
assert!(settings.federation_enabled);
assert!(settings.save_federated_on_listen);
assert!(settings.device_name.is_empty());
settings.network_id = "friends".into();
settings.device_name = "Studio Mac".into();
settings.library_path = "/music/library".into();
settings.federation_enabled = false;
store.save(&settings).unwrap();
assert_eq!(store.load().unwrap(), settings);
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
#[derive(Default)]
struct State {
available: u64,
complete: bool,
}
#[derive(Default)]
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
pub struct GrowingFileReader {
file: File,
pos: u64,
shared: Arc<Shared>,
}
pub struct GrowingFileWriter {
shared: Arc<Shared>,
}
pub fn growing_file(path: &Path) -> io::Result<(GrowingFileReader, GrowingFileWriter)> {
let shared = Arc::new(Shared::default());
Ok((
GrowingFileReader {
file: File::open(path)?,
pos: 0,
shared: Arc::clone(&shared),
},
GrowingFileWriter { shared },
))
}
impl GrowingFileWriter {
pub fn add_available(&self, bytes: u64) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.available = state.available.saturating_add(bytes);
self.shared.changed.notify_all();
}
pub fn finish(&self) {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.complete = true;
self.shared.changed.notify_all();
}
}
impl Drop for GrowingFileWriter {
fn drop(&mut self) {
self.finish();
}
}
impl Read for GrowingFileReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
loop {
let mut state = self
.shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while self.pos >= state.available && !state.complete {
state = self
.shared
.changed
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
if self.pos >= state.available {
return Ok(0);
}
let n = usize::try_from((state.available - self.pos).min(buf.len() as u64))
.unwrap_or(buf.len());
drop(state);
let read = self.file.read(&mut buf[..n])?;
self.pos += read as u64;
if read > 0 {
return Ok(read);
}
}
}
}
impl Seek for GrowingFileReader {
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"federated stream is not seekable while downloading",
))
}
}
+955
View File
@@ -0,0 +1,955 @@
use super::{
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, BuildInfoSnapshot,
CONTROL_POSITION_ACK_TOLERANCE_SECONDS, CatalogSource, ContentId, DevicePlaybackRole, Duration,
HashMap, HashSet, InternalEvent, LibrarySnapshot, LocalTrackId, PlaybackStatus,
PlaylistSnapshot, Release, ReleaseId, ReleaseKey, SearchResults, SettingsSnapshot,
SettingsStore, Track, TrackKey, VersionEntrySnapshot, federation, mpsc, std_mpsc, thread,
};
pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf {
if value == "~" {
directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().into())
} else if let Some(rest) = value.strip_prefix("~/") {
directories::UserDirs::new().map_or_else(|| value.into(), |dirs| dirs.home_dir().join(rest))
} else {
value.into()
}
}
pub(super) fn normalize_device_name(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
"furumi".into()
} else {
value.into()
}
}
pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) -> usize {
tracks
.iter()
.position(|track| track.key.matches(selected))
.unwrap_or(0)
}
pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
use music_dht::capabilities::{
CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID,
RENDEZVOUS_ID, TICKET_ID,
};
let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION"));
let protocol = |name: &str, id: &str| VersionEntrySnapshot {
name: name.into(),
version: manifest
.protocols
.get(id)
.map_or_else(|| "unknown".into(), u16::to_string),
};
BuildInfoSnapshot {
software: vec![
VersionEntrySnapshot {
name: "furumi-desktop".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
VersionEntrySnapshot {
name: "furumi-library".into(),
version: env!("FURUMI_LIBRARY_VERSION").into(),
},
VersionEntrySnapshot {
name: "music-dht".into(),
version: env!("FURUMI_MUSIC_DHT_VERSION").into(),
},
VersionEntrySnapshot {
name: "federation-net".into(),
version: env!("FURUMI_FEDERATION_NET_VERSION").into(),
},
],
protocols: vec![
protocol("Federation transport", FEDERATION_NET_ID),
protocol("Peer ticket", TICKET_ID),
protocol("Rendezvous", RENDEZVOUS_ID),
protocol("Music DHT", MUSIC_DHT_ID),
protocol("Catalog", CATALOG_ID),
VersionEntrySnapshot {
name: "Audio transfer".into(),
version: federation::AUDIO_PROTOCOL_VERSION.to_string(),
},
VersionEntrySnapshot {
name: "Connected devices".into(),
version: format!(
"{} · accepts v1",
manifest
.protocols
.get(DEVICE_SYNC_ID)
.copied()
.unwrap_or_default()
),
},
],
}
}
pub(super) fn find_catalog_track<'a>(
library: &'a LibrarySnapshot,
search: &'a SearchResults,
key: &TrackKey,
) -> Option<&'a Track> {
library
.featured_releases
.iter()
.flat_map(|release| release.tracks.iter())
.chain(
library
.playlists
.iter()
.flat_map(|playlist| playlist.tracks.iter()),
)
.chain(
search
.releases
.iter()
.flat_map(|release| release.tracks.iter()),
)
.chain(search.tracks.iter())
.find(|track| track.key.matches(key))
}
pub(super) fn portable_playback_placeholder(
wire: &music_dht::device_sync::PlaybackTrack,
content_id: ContentId,
) -> Track {
let peer_id = "content".to_owned();
let refs = |names: &[String]| {
names
.iter()
.map(|name| ArtistRef {
key: ArtistKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(name)),
},
name: name.clone(),
})
.collect::<Vec<_>>()
};
Track {
key: TrackKey::remote(content_id.clone()),
title: wire.title.clone(),
artist: playback_artist_line(&wire.artist_names, &wire.featured_artist_names),
artists: refs(&wire.artist_names),
featured_artists: refs(&wire.featured_artist_names),
release: wire.release_title.clone(),
release_id: ReleaseKey::Federation {
peer_id: peer_id.clone(),
id: format!("name:{}", music_dht::normalize_name(&wire.release_title)),
},
duration_seconds: wire.duration_seconds,
track_number: wire
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: wire.disc_number.and_then(|value| u32::try_from(value).ok()),
cover_uri: None,
audio_format: wire.audio_format.clone(),
audio_bitrate_kbps: wire
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: wire
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: wire
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: wire
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::Federation {
peer_id: String::new(),
content_id,
},
}
}
pub(super) fn playback_artist_line(main: &[String], featured: &[String]) -> String {
match (main.is_empty(), featured.is_empty()) {
(false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")),
(false, true) => main.join(", "),
(true, false) => format!("feat. {}", featured.join(", ")),
(true, true) => String::new(),
}
}
pub(super) fn extrapolated_control_position(
state: &music_dht::device_sync::PlaybackStateWire,
elapsed: Duration,
) -> f64 {
let elapsed = if state.playing && !state.paused {
elapsed.as_secs_f64()
} else {
0.0
};
(state.position_secs + elapsed).max(0.0)
}
pub(super) fn remote_snapshot_has_authority(
role: DevicePlaybackRole,
active_device_id: &str,
local_status: PlaybackStatus,
local_queue_empty: bool,
snapshot: &music_dht::device_sync::PlaybackSnapshot,
) -> bool {
if !snapshot.active {
return false;
}
match role {
DevicePlaybackRole::Control => snapshot.device_id == active_device_id,
DevicePlaybackRole::Active => local_status == PlaybackStatus::Stopped && local_queue_empty,
}
}
pub(super) fn playback_state_acknowledges_command(
expected: &music_dht::device_sync::PlaybackStateWire,
actual: &music_dht::device_sync::PlaybackStateWire,
seek: bool,
elapsed: Duration,
) -> bool {
let same_queue = expected.queue.len() == actual.queue.len()
&& expected
.queue
.iter()
.zip(&actual.queue)
.all(|(expected, actual)| playback_tracks_equivalent(expected, actual));
let same_transport = expected.queue_pos == actual.queue_pos
&& expected.playing == actual.playing
&& expected.paused == actual.paused
&& expected.volume == actual.volume
&& expected.shuffle == actual.shuffle
&& expected.repeat == actual.repeat;
if !same_queue || !same_transport {
return false;
}
if !seek {
return true;
}
let expected_position = extrapolated_control_position(expected, elapsed);
(expected_position - actual.position_secs).abs() <= CONTROL_POSITION_ACK_TOLERANCE_SECONDS
}
pub(super) fn playback_tracks_equivalent(
left: &music_dht::device_sync::PlaybackTrack,
right: &music_dht::device_sync::PlaybackTrack,
) -> bool {
let content_id = |track: &music_dht::device_sync::PlaybackTrack| {
track
.content_id
.as_deref()
.and_then(music_dht::normalize_content_id)
.or_else(|| {
track
.fed
.as_ref()
.and_then(|fed| music_dht::normalize_content_id(&fed.content_id))
})
};
match (content_id(left), content_id(right)) {
(Some(left), Some(right)) => left == right,
_ => {
music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title)
&& music_dht::normalize_name(&left.release_title)
== music_dht::normalize_name(&right.release_title)
&& left.track_number == right.track_number
&& left.disc_number == right.disc_number
&& normalized_artist_names(&left.artist_names)
== normalized_artist_names(&right.artist_names)
}
}
}
pub(super) fn normalized_artist_names(names: &[String]) -> Vec<String> {
names
.iter()
.map(|name| music_dht::normalize_name(name))
.collect()
}
pub(super) fn unix_time_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
})
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the playback volume is clamped to the wire protocol's 0..=100 range"
)]
pub(super) fn volume_percent(volume: f32) -> u8 {
(volume.clamp(0.0, 1.0) * 100.0).round() as u8
}
pub(super) fn track_to_synced_fed(track: &Track) -> Option<music_dht::device_sync::SyncedFedTrack> {
let (owner, item_id) = track.key.federation_id()?;
let content_id = track.key.content_id()?.as_str().to_owned();
Some(music_dht::device_sync::SyncedFedTrack {
item_id: item_id.to_owned(),
owner: owner.to_owned(),
title: track.title.clone(),
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
year: None,
duration_seconds: (track.duration_seconds.is_finite() && track.duration_seconds > 0.0)
.then(|| {
i64::try_from(Duration::from_secs_f64(track.duration_seconds).as_secs())
.unwrap_or(i64::MAX)
}),
content_id,
release_title: (!track.release.is_empty()).then(|| track.release.clone()),
track_number: track
.track_number
.and_then(|value| i32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| i32::try_from(value).ok()),
})
}
pub(super) fn track_to_library_fed(track: &Track) -> Option<furumi_library::FederatedTrack> {
let synced = track_to_synced_fed(track)?;
Some(furumi_library::FederatedTrack {
item_id: synced.item_id,
owner: synced.owner,
own: false,
title: synced.title,
artist_names: synced.artist_names,
featured_artist_names: synced.featured_artist_names,
year: synced.year,
duration_seconds: synced.duration_seconds,
content_id: Some(synced.content_id),
release_title: synced.release_title,
track_number: synced.track_number,
disc_number: synced.disc_number,
})
}
pub(super) fn track_to_playback_track(track: &Track) -> music_dht::device_sync::PlaybackTrack {
let id = track.key.local_id().map_or(-1, LocalTrackId::get);
let release_id = match track.release_id {
ReleaseKey::Local(id) => id.get(),
ReleaseKey::Federation { .. } => -1,
};
music_dht::device_sync::PlaybackTrack {
id,
title: track.title.clone(),
track_number: track
.track_number
.and_then(|value| i32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| i32::try_from(value).ok()),
duration_seconds: track.duration_seconds,
artist_names: track
.artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
featured_artist_names: track
.featured_artists
.iter()
.map(|artist| artist.name.clone())
.collect(),
release_id,
release_title: track.release.clone(),
release_year: None,
file_path: String::new(),
content_id: track.key.content_id().map(|id| id.as_str().to_owned()),
audio_format: track.audio_format.clone(),
audio_bitrate: track
.audio_bitrate_kbps
.and_then(|value| i32::try_from(value).ok()),
audio_sample_rate: track
.audio_sample_rate_hz
.and_then(|value| i32::try_from(value).ok()),
audio_bit_depth: track
.audio_bit_depth
.and_then(|value| i32::try_from(value).ok()),
file_size_bytes: track
.file_size_bytes
.and_then(|value| i64::try_from(value).ok()),
play_count: 0,
fed: track_to_synced_fed(track),
}
}
pub(super) fn sanitize_filename(value: &str) -> String {
let clean: String = value
.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, '-' | '_') {
c
} else {
'_'
}
})
.collect();
if clean.is_empty() {
"track".into()
} else {
clean
}
}
pub(super) fn federation_specs(
library: &furumi_library::Library,
) -> anyhow::Result<Vec<music_dht::ItemSpec>> {
let export = library.federation_export()?;
let mut specs = Vec::new();
for (id, name) in export.artists {
specs.push(music_dht::ItemSpec {
local_key: format!("artist:{id}"),
kind: music_dht::ItemKind::Artist,
name,
artist_names: Vec::new(),
featured_artist_names: Vec::new(),
year: None,
release_type: None,
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
for release in export.releases {
specs.push(music_dht::ItemSpec {
local_key: format!("release:{}", release.id),
kind: music_dht::ItemKind::Release,
name: release.title,
artist_names: release.artist_names,
featured_artist_names: Vec::new(),
year: release.year,
release_type: Some(release.release_type),
release_title: None,
track_number: None,
disc_number: None,
duration_seconds: None,
content_id: None,
});
}
for track in export.tracks {
specs.push(music_dht::ItemSpec {
local_key: format!("track:{}", track.id),
kind: music_dht::ItemKind::Track,
name: track.title,
artist_names: track.artist_names,
featured_artist_names: track.featured_artist_names,
year: track.year,
release_type: Some(track.release_type),
release_title: Some(track.release_title),
track_number: track.track_number,
disc_number: track.disc_number,
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
content_id: track.content_id,
});
}
Ok(specs)
}
pub(super) fn apply_federated_metadata(
import: &mut furumi_library::import::TrackImport,
metadata: Option<federation::TrackMetadata>,
) {
let Some(metadata) = metadata else {
return;
};
if !metadata.title.trim().is_empty() {
import.title = metadata.title;
}
if !metadata.artists.is_empty() {
import.artists = metadata.artists;
}
if !metadata.featured_artists.is_empty() {
import.featured_artists = metadata.featured_artists;
}
if !metadata.album_artists.is_empty() {
import.album_artists = metadata.album_artists;
}
if !metadata.release_title.trim().is_empty() {
import.release_title = metadata.release_title;
}
import.release_type = metadata.release_type.or(import.release_type.take());
import.year = metadata.year.or(import.year);
import.track_number = metadata.track_number.or(import.track_number);
import.disc_number = metadata.disc_number.or(import.disc_number);
import.duration_seconds = metadata.duration_seconds.unwrap_or(import.duration_seconds);
import.audio_format = metadata.audio_format.or(import.audio_format.take());
import.audio_bitrate = metadata.audio_bitrate.or(import.audio_bitrate);
import.audio_sample_rate = metadata.audio_sample_rate.or(import.audio_sample_rate);
import.audio_bit_depth = metadata.audio_bit_depth.or(import.audio_bit_depth);
}
pub(super) fn spawn_settings_worker(
store: SettingsStore,
receiver: std_mpsc::Receiver<SettingsSnapshot>,
events: mpsc::Sender<InternalEvent>,
) {
let report = events.clone();
if let Err(error) = thread::Builder::new()
.name("furumi-settings-storage".into())
.spawn(move || {
while let Ok(mut settings) = receiver.recv() {
for newer in receiver.try_iter() {
settings = newer;
}
let result = store.save(&settings).map_err(|error| error.to_string());
if events
.blocking_send(InternalEvent::SettingsPersisted(result))
.is_err()
{
break;
}
}
})
{
let _ = report.blocking_send(InternalEvent::SettingsPersisted(Err(format!(
"settings worker: {error}"
))));
}
}
pub(super) fn local_search_results(
catalog: &furumi_library::Library,
query: &str,
) -> anyhow::Result<SearchResults> {
let found = catalog.search(query, 50)?;
let artists = found
.artists
.into_iter()
.map(|artist| Artist {
key: ArtistKey::local(ArtistId::new(artist.id)),
source: CatalogSource::Local,
name: artist.name,
artwork: Artwork {
uri: artist.image_path,
},
release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX),
track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX),
})
.collect();
let mut releases = Vec::with_capacity(found.releases.len());
for card in found.releases {
let detail = catalog.release(card.id)?;
releases.push(library_release(detail));
}
let tracks = found
.tracks
.into_iter()
.map(|track| library_track(track, ""))
.collect();
Ok(SearchResults {
artists,
releases,
tracks,
})
}
pub(super) fn merge_search_results(target: &mut SearchResults, incoming: SearchResults) {
for artist in incoming.artists {
if let Some(existing) = target
.artists
.iter_mut()
.find(|item| item.key == artist.key)
{
existing.release_count = existing.release_count.max(artist.release_count);
existing.track_count = existing.track_count.max(artist.track_count);
if existing.artwork.uri.is_none() && artist.artwork.uri.is_some() {
existing.artwork = artist.artwork;
}
} else {
target.artists.push(artist);
}
}
for mut release in incoming.releases {
populate_release_contributors(&mut release);
sort_release_tracks(&mut release.tracks);
if let Some(existing) = target
.releases
.iter_mut()
.find(|item| item.key == release.key)
{
merge_release_preserving_local(existing, release);
} else {
target.releases.push(release);
}
}
for track in incoming.tracks {
if let Some(existing) = target
.tracks
.iter_mut()
.find(|item| item.same_catalog_track(&track))
{
if matches!(existing.audio_source, AudioSource::Federation { .. })
&& matches!(track.audio_source, AudioSource::LocalFile(_))
{
*existing = track;
}
} else {
target.tracks.push(track);
}
}
}
pub(super) fn merge_release_preserving_local(target: &mut Release, incoming: Release) {
if matches!(target.source, CatalogSource::Federation { .. })
&& matches!(incoming.source, CatalogSource::Local)
{
let previous = std::mem::replace(target, incoming);
merge_release_preserving_local(target, previous);
return;
}
if target.artwork.uri.is_none() && incoming.artwork.uri.is_some() {
target.artwork = incoming.artwork;
}
merge_artist_refs(&mut target.artists, incoming.artists);
merge_artist_refs(&mut target.featured_artists, incoming.featured_artists);
for track in incoming.tracks {
if let Some(current) = target
.tracks
.iter_mut()
.find(|item| tracks_match_within_release(item, &track))
{
if matches!(current.audio_source, AudioSource::Federation { .. })
&& matches!(track.audio_source, AudioSource::LocalFile(_))
{
*current = track;
}
} else {
target.tracks.push(track);
}
}
sort_release_tracks(&mut target.tracks);
populate_release_contributors(target);
}
fn sort_release_tracks(tracks: &mut [Track]) {
tracks.sort_by(|left, right| {
left.disc_number
.unwrap_or(1)
.cmp(&right.disc_number.unwrap_or(1))
.then_with(|| {
left.track_number
.unwrap_or(u32::MAX)
.cmp(&right.track_number.unwrap_or(u32::MAX))
})
.then_with(|| left.title.cmp(&right.title))
});
}
/// Match provider records within an already identified release. Track/disc
/// position is more reliable here than optional remote descriptive metadata.
pub(super) fn tracks_match_within_release(left: &Track, right: &Track) -> bool {
if left.key.matches(&right.key) {
return true;
}
if let (Some(left_number), Some(right_number)) = (left.track_number, right.track_number)
&& left_number == right_number
&& left.disc_number.unwrap_or(1) == right.disc_number.unwrap_or(1)
{
return true;
}
music_dht::normalize_name(&left.title) == music_dht::normalize_name(&right.title)
}
pub(super) fn merge_artist_refs(target: &mut Vec<ArtistRef>, incoming: Vec<ArtistRef>) {
let mut known = target
.iter()
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
target.extend(
incoming
.into_iter()
.filter(|artist| known.insert(music_dht::normalize_name(&artist.name))),
);
}
pub(super) fn populate_release_contributors(release: &mut Release) {
let mut known = release
.artists
.iter()
.chain(release.featured_artists.iter())
.map(|artist| music_dht::normalize_name(&artist.name))
.collect::<HashSet<_>>();
for artist in release
.tracks
.iter()
.flat_map(|track| track.artists.iter().chain(track.featured_artists.iter()))
{
if known.insert(music_dht::normalize_name(&artist.name)) {
release.featured_artists.push(artist.clone());
}
}
}
pub(super) fn library_release(detail: furumi_library::ReleaseDetail) -> Release {
let (artists, featured_artists) = release_artist_roles(&detail);
let fallback = artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
Release {
key: ReleaseKey::local(ReleaseId::new(detail.id)),
source: CatalogSource::Local,
title: detail.title,
artists,
featured_artists,
release_type: detail.release_type,
year: detail.year,
artwork: Artwork {
uri: detail.cover_path,
},
tracks: detail
.tracks
.into_iter()
.map(|track| library_track(track, &fallback))
.collect(),
}
}
pub(super) fn library_snapshot(
catalog: &furumi_library::Library,
) -> anyhow::Result<LibrarySnapshot> {
let liked_ids = catalog
.liked_content_ids()?
.into_iter()
.chain(catalog.fed_like_ids()?)
.collect::<HashSet<_>>();
let artist_cards = catalog.artists(
1,
i64::MAX,
furumi_library::LibraryFilters {
source_mode: furumi_library::LibrarySourceMode::Local,
..furumi_library::LibraryFilters::default()
},
)?;
let artists = artist_cards
.items
.into_iter()
.map(|artist| Artist {
key: ArtistKey::local(ArtistId::new(artist.id)),
source: CatalogSource::Local,
name: artist.name,
artwork: Artwork {
uri: artist.image_path,
},
release_count: usize::try_from(artist.release_count.max(0)).unwrap_or(usize::MAX),
track_count: usize::try_from(artist.track_count.max(0)).unwrap_or(usize::MAX),
})
.collect();
let mut releases = Vec::new();
for card in catalog.releases()? {
let detail = catalog.release(card.id)?;
let (artist_refs, featured_artist_refs) = release_artist_roles(&detail);
let artist_line = artist_refs
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let mut tracks = detail
.tracks
.into_iter()
.map(|track| library_track(track, &artist_line))
.collect::<Vec<_>>();
for track in &mut tracks {
track.liked = track_is_liked(track, &liked_ids);
}
releases.push(Release {
key: ReleaseKey::local(ReleaseId::new(detail.id)),
source: CatalogSource::Local,
title: detail.title,
artists: artist_refs,
featured_artists: featured_artist_refs,
release_type: detail.release_type,
year: detail.year,
artwork: Artwork {
uri: detail.cover_path,
},
tracks,
});
}
let recently_played = releases
.iter()
.flat_map(|release| release.tracks.iter().cloned())
.take(12)
.collect();
let mut playlists = Vec::new();
for card in catalog.playlists()? {
let detail = catalog.playlist(card.id)?;
let mut tracks = detail
.tracks
.into_iter()
.map(|track| library_track(track, ""))
.collect::<Vec<_>>();
for track in &mut tracks {
track.liked = track_is_liked(track, &liked_ids);
}
playlists.push(PlaylistSnapshot {
id: card.id,
title: detail.title,
is_likes: card.kind == "likes",
tracks,
});
}
Ok(LibrarySnapshot {
artists,
featured_releases: releases,
recently_played,
playlists,
})
}
pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet<String>) -> bool {
track
.key
.content_id()
.is_some_and(|id| liked_ids.contains(id.as_str()))
|| track
.key
.federation_id()
.is_some_and(|(_, item)| liked_ids.contains(item))
}
pub(super) fn release_artist_roles(
detail: &furumi_library::ReleaseDetail,
) -> (Vec<ArtistRef>, Vec<ArtistRef>) {
let mut main_counts = HashMap::<i64, usize>::new();
let mut featured = HashMap::<i64, String>::new();
for track in &detail.tracks {
for artist in &track.artists {
*main_counts.entry(artist.id).or_default() += 1;
}
for artist in &track.featured_artists {
featured
.entry(artist.id)
.or_insert_with(|| artist.name.clone());
}
}
let mut main_artists = Vec::new();
let mut featured_artists = Vec::new();
let mut known = HashSet::new();
for artist in &detail.artists {
known.insert(artist.id);
let reference = ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
};
if main_counts.contains_key(&artist.id) || !featured.contains_key(&artist.id) {
main_artists.push(reference);
} else {
featured_artists.push(reference);
}
}
for track in &detail.tracks {
for artist in &track.artists {
if known.insert(artist.id) {
main_artists.push(ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
});
}
}
}
for (id, name) in featured {
if known.insert(id) {
featured_artists.push(ArtistRef {
key: ArtistKey::local(ArtistId::new(id)),
name,
});
}
}
main_artists.sort_by(|left, right| {
let count = |artist: &ArtistRef| match artist.key {
ArtistKey::Local(id) => main_counts.get(&id.get()).copied().unwrap_or_default(),
ArtistKey::Federation { .. } => 0,
};
count(right).cmp(&count(left))
});
(main_artists, featured_artists)
}
pub(super) fn library_track(track: furumi_library::TrackItem, fallback_artist: &str) -> Track {
let content_id = track
.content_id
.as_deref()
.and_then(|content_id| ContentId::parse(content_id).ok());
let local_id = LocalTrackId::new(track.id);
let key = content_id.map_or_else(
|| TrackKey::local(local_id),
|content_id| TrackKey::new(local_id, content_id),
);
let artists = track
.artists
.iter()
.map(|artist| ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
})
.collect::<Vec<_>>();
let featured_artists = track
.featured_artists
.iter()
.map(|artist| ArtistRef {
key: ArtistKey::local(ArtistId::new(artist.id)),
name: artist.name.clone(),
})
.collect::<Vec<_>>();
let artist = {
let value = track.artist_line();
if value.is_empty() {
fallback_artist.to_string()
} else {
value
}
};
Track {
key,
title: track.title,
artist,
artists,
featured_artists,
release: track.release_title,
release_id: ReleaseKey::local(ReleaseId::new(track.release_id)),
duration_seconds: track.duration_seconds,
track_number: track
.track_number
.and_then(|value| u32::try_from(value).ok()),
disc_number: track
.disc_number
.and_then(|value| u32::try_from(value).ok()),
cover_uri: track.cover_path,
audio_format: track.audio_format,
audio_bitrate_kbps: track
.audio_bitrate
.and_then(|value| u32::try_from(value).ok()),
audio_sample_rate_hz: track
.audio_sample_rate
.and_then(|value| u32::try_from(value).ok()),
audio_bit_depth: track
.audio_bit_depth
.and_then(|value| u32::try_from(value).ok()),
file_size_bytes: track
.file_size_bytes
.and_then(|value| u64::try_from(value).ok()),
liked: false,
audio_source: AudioSource::LocalFile(track.file_path.into()),
}
}
+462
View File
@@ -0,0 +1,462 @@
use super::*;
fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
Track {
key,
title: "Track".into(),
artist: "Artist".into(),
artists: vec![ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
}],
featured_artists: Vec::new(),
release: "Album".into(),
release_id: ReleaseKey::local(ReleaseId::new(1)),
duration_seconds: 180.0,
track_number: Some(1),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("flac".into()),
audio_bitrate_kbps: None,
audio_sample_rate_hz: None,
audio_bit_depth: None,
file_size_bytes: None,
liked: false,
audio_source,
}
}
#[test]
fn merging_search_results_deduplicates_source_keys() {
let artist = Artist {
key: ArtistKey::Federation {
peer_id: "peer".into(),
id: "artist".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
name: "Artist".into(),
artwork: Artwork::default(),
release_count: 0,
track_count: 0,
};
let mut target = SearchResults {
artists: vec![artist.clone()],
..SearchResults::default()
};
merge_search_results(
&mut target,
SearchResults {
artists: vec![artist],
..SearchResults::default()
},
);
assert_eq!(target.artists.len(), 1);
}
#[test]
fn newly_received_release_tracks_are_sorted_by_disc_and_track_number() {
let mut fifth = merge_test_track(
TrackKey::federation("peer".into(), "five".into(), None),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: ContentId::parse(format!("b3:{}", "5".repeat(64))).unwrap(),
},
);
fifth.title = "Los".into();
fifth.track_number = Some(5);
let mut first = merge_test_track(
TrackKey::federation("peer".into(), "one".into(), None),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap(),
},
);
first.title = "Reise, Reise".into();
let mut target = SearchResults::default();
merge_search_results(
&mut target,
SearchResults {
releases: vec![Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: "reise-reise".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Reise, Reise".into(),
artists: first.artists.clone(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2004),
artwork: Artwork::default(),
tracks: vec![fifth, first],
}],
..SearchResults::default()
},
);
assert_eq!(
target.releases[0]
.tracks
.iter()
.map(|track| track.track_number)
.collect::<Vec<_>>(),
vec![Some(1), Some(5)]
);
}
#[test]
fn selected_track_key_survives_context_filtering_and_reordering() {
let selected = TrackKey::local(LocalTrackId::new(2));
let resolved = vec![
merge_test_track(
TrackKey::local(LocalTrackId::new(3)),
AudioSource::LocalFile("three.flac".into()),
),
merge_test_track(selected.clone(), AudioSource::LocalFile("two.flac".into())),
];
assert_eq!(selected_track_position(&resolved, &selected), 1);
}
#[test]
fn queue_artwork_requests_are_grouped_per_peer_and_release() {
let first_content = ContentId::parse(format!("b3:{}", "1".repeat(64))).unwrap();
let second_content = ContentId::parse(format!("b3:{}", "2".repeat(64))).unwrap();
let mut first = merge_test_track(
TrackKey::federation("peer".into(), "one".into(), Some(first_content.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: first_content,
},
);
let mut second = merge_test_track(
TrackKey::federation("peer".into(), "two".into(), Some(second_content.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id: second_content,
},
);
first.title = "One".into();
second.title = "Two".into();
assert_eq!(
Actor::queue_artwork_request_id(&first),
Actor::queue_artwork_request_id(&second)
);
second.release = "Another album".into();
assert_ne!(
Actor::queue_artwork_request_id(&first),
Actor::queue_artwork_request_id(&second)
);
}
#[test]
fn federated_release_enrichment_never_replaces_a_local_track() {
let release_key = ReleaseKey::local(ReleaseId::new(1));
let artist = ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
};
let local_track = merge_test_track(
TrackKey::local(LocalTrackId::new(1)),
AudioSource::LocalFile("track.flac".into()),
);
let mut target = SearchResults {
releases: vec![Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Album".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![local_track],
}],
..SearchResults::default()
};
let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let remote_track = merge_test_track(
TrackKey::federation(
"peer".into(),
"remote-item".into(),
Some(content_id.clone()),
),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
merge_search_results(
&mut target,
SearchResults {
releases: vec![Release {
key: release_key,
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Album".into(),
artists: vec![artist],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![remote_track],
}],
..SearchResults::default()
},
);
let tracks = &target.releases[0].tracks;
assert_eq!(tracks.len(), 1);
assert!(matches!(tracks[0].audio_source, AudioSource::LocalFile(_)));
}
#[test]
fn incomplete_federated_metadata_cannot_shadow_local_release_metadata() {
let release_key = ReleaseKey::local(ReleaseId::new(1));
let artist = ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "Artist".into(),
};
let mut local_track = merge_test_track(
TrackKey::local(LocalTrackId::new(1)),
AudioSource::LocalFile("track.flac".into()),
);
local_track.cover_uri = Some("local-cover.png".into());
local_track.audio_bitrate_kbps = Some(1_411);
let mut target = Release {
key: release_key.clone(),
source: CatalogSource::Local,
title: "Complete local album title".into(),
artists: vec![artist.clone()],
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork {
uri: Some("local-cover.png".into()),
},
tracks: vec![local_track],
};
let content_id = ContentId::parse(format!("b3:{}", "b".repeat(64))).unwrap();
let mut incomplete_remote = merge_test_track(
TrackKey::federation("peer".into(), "item".into(), Some(content_id.clone())),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
incomplete_remote.title = String::new();
incomplete_remote.artist = String::new();
incomplete_remote.artists.clear();
incomplete_remote.audio_format = None;
merge_release_preserving_local(
&mut target,
Release {
key: release_key,
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: String::new(),
artists: vec![artist],
featured_artists: Vec::new(),
release_type: String::new(),
year: None,
artwork: Artwork::default(),
tracks: vec![incomplete_remote],
},
);
assert!(matches!(target.source, CatalogSource::Local));
assert_eq!(target.title, "Complete local album title");
assert_eq!(target.tracks.len(), 1);
assert_eq!(target.tracks[0].title, "Track");
assert_eq!(target.tracks[0].audio_format.as_deref(), Some("flac"));
assert_eq!(target.tracks[0].audio_bitrate_kbps, Some(1_411));
assert!(matches!(
target.tracks[0].audio_source,
AudioSource::LocalFile(_)
));
}
#[test]
fn queue_resolver_keeps_tracks_nested_in_a_federated_release() {
let content_id = ContentId::parse(format!("b3:{}", "c".repeat(64))).unwrap();
let key = TrackKey::federation(
"peer".into(),
"remote-track".into(),
Some(content_id.clone()),
);
let track = merge_test_track(
key.clone(),
AudioSource::Federation {
peer_id: "peer".into(),
content_id,
},
);
let search = SearchResults {
releases: vec![Release {
key: ReleaseKey::Federation {
peer_id: "peer".into(),
id: "album".into(),
},
source: CatalogSource::Federation {
peer_id: "peer".into(),
},
title: "Album".into(),
artists: track.artists.clone(),
featured_artists: Vec::new(),
release_type: "album".into(),
year: Some(2026),
artwork: Artwork::default(),
tracks: vec![track],
}],
..SearchResults::default()
};
let library = LibrarySnapshot::default();
let resolved = find_catalog_track(&library, &search, &key);
assert!(resolved.is_some());
assert!(matches!(
resolved.unwrap().audio_source,
AudioSource::Federation { .. }
));
}
#[test]
fn portable_device_queue_keeps_unresolved_content_as_a_federated_placeholder() {
let content_id = ContentId::parse(format!("b3:{}", "d".repeat(64))).unwrap();
let wire = music_dht::device_sync::PlaybackTrack {
id: 12,
title: "Portable track".into(),
track_number: Some(2),
disc_number: Some(1),
duration_seconds: 123.0,
artist_names: vec!["Artist".into()],
featured_artist_names: vec!["Guest".into()],
release_id: 4,
release_title: "Release".into(),
release_year: Some(2026),
file_path: String::new(),
content_id: Some(content_id.as_str().into()),
audio_format: Some("flac".into()),
audio_bitrate: Some(1_411),
audio_sample_rate: Some(44_100),
audio_bit_depth: Some(16),
file_size_bytes: Some(42),
play_count: 0,
fed: None,
};
let placeholder = portable_playback_placeholder(&wire, content_id.clone());
assert_eq!(placeholder.key.content_id(), Some(&content_id));
assert_eq!(placeholder.artist, "Artist feat. Guest");
assert!(matches!(
placeholder.audio_source,
AudioSource::Federation { ref peer_id, .. } if peer_id.is_empty()
));
}
fn playback_test_state(content_byte: char) -> music_dht::device_sync::PlaybackStateWire {
music_dht::device_sync::PlaybackStateWire {
queue: vec![music_dht::device_sync::PlaybackTrack {
id: 1,
title: format!("Track {content_byte}"),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 180.0,
artist_names: vec!["Artist".into()],
featured_artist_names: Vec::new(),
release_id: 1,
release_title: "Album".into(),
release_year: Some(2026),
file_path: String::new(),
content_id: Some(format!("b3:{}", content_byte.to_string().repeat(64))),
audio_format: Some("flac".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
}],
queue_pos: 0,
playing: true,
paused: false,
idle_since_ms: None,
position_secs: 40.0,
volume: 72,
shuffle: false,
repeat: music_dht::device_sync::PlaybackRepeat::Off,
}
}
#[test]
fn control_device_ignores_snapshots_from_the_previous_active_device() {
let snapshot = music_dht::device_sync::PlaybackSnapshot {
device_id: "old-device".into(),
device_name: "Old".into(),
active: true,
updated_at_ms: 1,
state: playback_test_state('a'),
};
assert!(!remote_snapshot_has_authority(
DevicePlaybackRole::Control,
"current-device",
PlaybackStatus::Playing,
false,
&snapshot,
));
assert!(remote_snapshot_has_authority(
DevicePlaybackRole::Control,
"old-device",
PlaybackStatus::Playing,
false,
&snapshot,
));
}
#[test]
fn stale_remote_state_cannot_ack_a_new_control_command() {
let expected = playback_test_state('a');
let stale_track = playback_test_state('b');
assert!(!playback_state_acknowledges_command(
&expected,
&stale_track,
true,
Duration::from_secs(2),
));
let mut acknowledged = expected.clone();
acknowledged.position_secs = 42.0;
assert!(playback_state_acknowledges_command(
&expected,
&acknowledged,
true,
Duration::from_secs(2),
));
acknowledged.position_secs = 5.0;
assert!(!playback_state_acknowledges_command(
&expected,
&acknowledged,
true,
Duration::from_secs(2),
));
assert!(playback_state_acknowledges_command(
&expected,
&acknowledged,
false,
Duration::from_secs(2),
));
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "furumi-domain"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
+555
View File
@@ -0,0 +1,555 @@
//! Pure Furumi domain types and rules.
use std::fmt;
use std::path::PathBuf;
macro_rules! local_id {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name(i64);
impl $name {
#[must_use]
pub const fn new(value: i64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> i64 {
self.0
}
}
};
}
local_id!(ArtistId);
local_id!(ReleaseId);
local_id!(LocalTrackId);
/// Origin of catalog metadata. UI and application code render the same
/// entities regardless of which provider supplied them.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CatalogSource {
Local,
Federation { peer_id: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ArtistKey {
Local(ArtistId),
Federation { peer_id: String, id: String },
}
impl ArtistKey {
#[must_use]
pub const fn local(id: ArtistId) -> Self {
Self::Local(id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReleaseKey {
Local(ReleaseId),
Federation { peer_id: String, id: String },
}
impl ReleaseKey {
#[must_use]
pub const fn local(id: ReleaseId) -> Self {
Self::Local(id)
}
}
/// Artwork resolved by a catalog provider. Federation can initially publish
/// `None`, fetch the image into its cache, and then replace the snapshot with
/// the same entity and a local URI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Artwork {
pub uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtistRef {
pub key: ArtistKey,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artist {
pub key: ArtistKey,
pub source: CatalogSource,
pub name: String,
pub artwork: Artwork,
pub release_count: usize,
pub track_count: usize,
}
/// Stable Frid/Furumi audio identity (`b3:<64 lowercase hex>`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ContentId(String);
impl ContentId {
/// Parses and normalizes an audio content identifier.
///
/// # Errors
///
/// Returns [`InvalidContentId`] unless the value is `b3:` followed by 64
/// hexadecimal characters.
pub fn parse(value: impl Into<String>) -> Result<Self, InvalidContentId> {
let value = value.into().to_ascii_lowercase();
let hash = value.strip_prefix("b3:").ok_or(InvalidContentId)?;
if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(InvalidContentId);
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidContentId;
impl fmt::Display for InvalidContentId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("content ID must be b3:<64 hex characters>")
}
}
impl std::error::Error for InvalidContentId {}
/// A track can be known by its local database ID, stable content ID, or both.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TrackKey {
local_id: Option<LocalTrackId>,
content_id: Option<ContentId>,
federation_identity: Option<(String, String)>,
}
impl TrackKey {
#[must_use]
pub const fn local(id: LocalTrackId) -> Self {
Self {
local_id: Some(id),
content_id: None,
federation_identity: None,
}
}
#[must_use]
pub const fn new(local_id: LocalTrackId, content_id: ContentId) -> Self {
Self {
local_id: Some(local_id),
content_id: Some(content_id),
federation_identity: None,
}
}
#[must_use]
pub const fn remote(content_id: ContentId) -> Self {
Self {
local_id: None,
content_id: Some(content_id),
federation_identity: None,
}
}
#[must_use]
pub fn federation(peer_id: String, item_id: String, content_id: Option<ContentId>) -> Self {
Self {
local_id: None,
content_id,
federation_identity: Some((peer_id, item_id)),
}
}
#[must_use]
pub const fn local_id(&self) -> Option<LocalTrackId> {
self.local_id
}
#[must_use]
pub const fn content_id(&self) -> Option<&ContentId> {
self.content_id.as_ref()
}
#[must_use]
pub fn federation_id(&self) -> Option<(&str, &str)> {
self.federation_identity
.as_ref()
.map(|(peer, item)| (peer.as_str(), item.as_str()))
}
#[must_use]
pub fn matches(&self, other: &Self) -> bool {
self.content_id
.as_ref()
.zip(other.content_id.as_ref())
.is_some_and(|(left, right)| left == right)
|| self
.local_id
.zip(other.local_id)
.is_some_and(|(left, right)| left == right)
|| self
.federation_identity
.as_ref()
.zip(other.federation_identity.as_ref())
.is_some_and(|(left, right)| left == right)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Track {
pub key: TrackKey,
pub title: String,
pub artist: String,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release: String,
pub release_id: ReleaseKey,
pub duration_seconds: f64,
pub track_number: Option<u32>,
pub disc_number: Option<u32>,
pub cover_uri: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate_kbps: Option<u32>,
pub audio_sample_rate_hz: Option<u32>,
pub audio_bit_depth: Option<u32>,
pub file_size_bytes: Option<u64>,
/// Whether this content is present in the listener's shared likes.
pub liked: bool,
pub audio_source: AudioSource,
}
impl Track {
/// Returns whether two provider records describe the same logical track.
/// Stable content identity wins, with album metadata as a fallback for
/// catalogs that did not publish a content ID.
#[must_use]
pub fn same_catalog_track(&self, other: &Self) -> bool {
if self.key.matches(&other.key) {
return true;
}
if normalized_catalog_text(&self.title) != normalized_catalog_text(&other.title) {
return false;
}
let left_release = normalized_catalog_text(&self.release);
let right_release = normalized_catalog_text(&other.release);
if !left_release.is_empty() && !right_release.is_empty() && left_release != right_release {
return false;
}
if let (Some(left), Some(right)) = (self.track_number, other.track_number) {
return left == right
&& self.disc_number.unwrap_or(1) == other.disc_number.unwrap_or(1);
}
self.duration_seconds > 0.0
&& other.duration_seconds > 0.0
&& (self.duration_seconds - other.duration_seconds).abs() < 2.0
}
}
fn normalized_catalog_text(value: &str) -> String {
value.trim().to_lowercase()
}
/// Playback location resolved by a catalog provider. Local files go directly
/// to the audio engine; federated sources are streamed or materialized first
/// and then handed to the same engine.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AudioSource {
LocalFile(PathBuf),
Federation {
peer_id: String,
content_id: ContentId,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Release {
pub key: ReleaseKey,
pub source: CatalogSource,
pub title: String,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release_type: String,
pub year: Option<i32>,
pub artwork: Artwork,
pub tracks: Vec<Track>,
}
impl Release {
#[must_use]
pub fn artist_line(&self) -> String {
self.artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn is_album(&self) -> bool {
self.release_type.eq_ignore_ascii_case("album")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct QueueItemId(u64);
impl QueueItemId {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QueueItem {
pub id: QueueItemId,
pub track: Track,
}
/// Logical queue shared with the other Furumi players.
///
/// `play_next_end` is the exclusive end of the stable FIFO block created by
/// successive "play next" commands.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Queue {
items: Vec<QueueItem>,
current: Option<usize>,
play_next_end: Option<usize>,
next_item_id: u64,
}
impl Queue {
#[must_use]
pub fn items(&self) -> &[QueueItem] {
&self.items
}
#[must_use]
pub const fn current_index(&self) -> Option<usize> {
self.current
}
#[must_use]
pub fn current(&self) -> Option<&QueueItem> {
self.current.and_then(|index| self.items.get(index))
}
/// Selects a concrete queue occurrence without rebuilding the queue.
pub fn select_item(&mut self, id: QueueItemId) -> Option<&QueueItem> {
self.current = self.items.iter().position(|item| item.id == id);
self.play_next_end = None;
self.current()
}
pub fn replace_context(&mut self, tracks: Vec<Track>, start: usize) {
let mut items = Vec::with_capacity(tracks.len());
for track in tracks {
items.push(self.new_item(track));
}
self.items = items;
self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1));
self.play_next_end = None;
}
pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) {
let items: Vec<_> = tracks
.into_iter()
.map(|track| self.new_item(track))
.collect();
self.items.extend(items);
}
pub fn add_next(&mut self, tracks: impl IntoIterator<Item = Track>) {
let items: Vec<_> = tracks
.into_iter()
.map(|track| self.new_item(track))
.collect();
if items.is_empty() {
return;
}
let insertion = self.current.map_or(0, |current| {
self.play_next_end
.filter(|end| *end > current && *end <= self.items.len())
.unwrap_or((current + 1).min(self.items.len()))
});
let count = items.len();
self.items.splice(insertion..insertion, items);
if self.current.is_some_and(|current| insertion <= current) {
self.current = self.current.map(|current| current + count);
}
self.play_next_end = Some(insertion + count);
}
pub fn replace_matching_track(&mut self, replacement: &Track) {
for item in &mut self.items {
if item.track.key.matches(&replacement.key) {
item.track = replacement.clone();
}
}
}
pub fn replace_track(&mut self, key: &TrackKey, replacement: &Track) {
for item in &mut self.items {
if item.track.key.matches(key) {
item.track = replacement.clone();
}
}
}
pub fn advance(&mut self) -> Option<&QueueItem> {
let next = match self.current {
None if !self.items.is_empty() => 0,
Some(current) if current + 1 < self.items.len() => current + 1,
_ => return None,
};
self.current = Some(next);
self.normalize_play_next_block();
self.current()
}
pub fn previous(&mut self) -> Option<&QueueItem> {
let previous = self.current?.saturating_sub(1);
self.current = Some(previous);
self.normalize_play_next_block();
self.current()
}
fn new_item(&mut self, track: Track) -> QueueItem {
self.next_item_id = self.next_item_id.saturating_add(1);
QueueItem {
id: QueueItemId::new(self.next_item_id),
track,
}
}
fn normalize_play_next_block(&mut self) {
if self.play_next_end.is_some_and(|end| {
self.current.is_none_or(|current| end <= current) || end > self.items.len()
}) {
self.play_next_end = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(id: i64) -> Track {
Track {
key: TrackKey::local(LocalTrackId::new(id)),
title: id.to_string(),
artist: "artist".into(),
artists: vec![ArtistRef {
key: ArtistKey::local(ArtistId::new(1)),
name: "artist".into(),
}],
featured_artists: Vec::new(),
release: "release".into(),
release_id: ReleaseKey::local(ReleaseId::new(1)),
duration_seconds: 180.0,
track_number: Some(u32::try_from(id).unwrap()),
disc_number: Some(1),
cover_uri: None,
audio_format: Some("FLAC".into()),
audio_bitrate_kbps: Some(900),
audio_sample_rate_hz: Some(44_100),
audio_bit_depth: Some(16),
file_size_bytes: None,
liked: false,
audio_source: AudioSource::LocalFile(PathBuf::from(format!("track-{id}.flac"))),
}
}
#[test]
fn sequential_play_next_additions_form_a_fifo_block() {
let mut queue = Queue::default();
queue.replace_context((10..=13).map(track).collect(), 0);
queue.add_next([track(1)]);
queue.add_next([track(2)]);
queue.add_next([track(3)]);
let ids: Vec<_> = queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
assert_eq!(ids, vec![10, 1, 2, 3, 11, 12, 13]);
}
#[test]
fn replacing_context_keeps_order_and_starts_at_selected_track() {
let mut queue = Queue::default();
queue.replace_context((10..=13).map(track).collect(), 2);
let ids: Vec<_> = queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
assert_eq!(ids, vec![10, 11, 12, 13]);
assert_eq!(queue.current_index(), Some(2));
assert_eq!(
queue.current().unwrap().track.key.local_id().unwrap().get(),
12
);
}
#[test]
fn catalog_track_matching_falls_back_to_album_position() {
let local = track(1);
let content_id = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let mut federated = local.clone();
federated.key = TrackKey::federation(
"peer".into(),
"remote-item".into(),
Some(content_id.clone()),
);
federated.audio_source = AudioSource::Federation {
peer_id: "peer".into(),
content_id,
};
assert!(local.same_catalog_track(&federated));
federated.track_number = Some(2);
assert!(!local.same_catalog_track(&federated));
}
#[test]
fn content_id_matches_furumi_wire_format() {
let id = ContentId::parse(format!("B3:{}", "A".repeat(64))).unwrap();
assert_eq!(id.as_str(), format!("b3:{}", "a".repeat(64)));
assert!(ContentId::parse("track-1").is_err());
}
#[test]
fn catalog_keys_keep_local_and_federated_entities_distinct() {
let local = ReleaseKey::local(ReleaseId::new(7));
let remote = ReleaseKey::Federation {
peer_id: "peer-a".into(),
id: "7".into(),
};
assert_ne!(local, remote);
let content = ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
let first = TrackKey::federation("peer-a".into(), "item-1".into(), Some(content.clone()));
let same_audio_elsewhere =
TrackKey::federation("peer-b".into(), "item-9".into(), Some(content));
let other = TrackKey::federation("peer-a".into(), "item-2".into(), None);
assert!(first.matches(&same_audio_elsewhere));
assert!(!first.matches(&other));
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "furumi-platform-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
rfd.workspace = true
souvlaki.workspace = true
[target.'cfg(target_os="macos")'.dependencies]
core-foundation.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
[lints.rust]
unsafe_code = "allow"
[lints.clippy]
all = "warn"
pedantic = "warn"
+18
View File
@@ -0,0 +1,18 @@
//! Narrow adapters for operating-system desktop services.
use std::path::{Path, PathBuf};
mod media;
pub use media::{MediaCommand, MediaSession};
/// Opens the platform-native directory picker.
///
/// The dialog is blocking and must be invoked away from the Slint event loop.
#[must_use]
pub fn choose_library_directory(initial: Option<&Path>) -> Option<PathBuf> {
let mut dialog = rfd::FileDialog::new().set_title("Choose Furumi library folder");
if let Some(path) = initial {
dialog = dialog.set_directory(path);
}
dialog.pick_folder()
}
+185
View File
@@ -0,0 +1,185 @@
//! Cross-platform OS media session backed by souvlaki.
use std::time::{Duration, Instant};
use souvlaki::{
MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, MediaPosition, PlatformConfig,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaCommand {
Toggle,
Play,
Pause,
Next,
Previous,
Stop,
}
pub struct MediaSession {
controls: MediaControls,
metadata: Option<(String, String, String, u64)>,
playback: Option<(bool, bool)>,
last_position_update: Option<Instant>,
}
impl MediaSession {
pub fn new(on_command: impl Fn(MediaCommand) + Send + 'static) -> Option<Self> {
let dbus_name = platform_dbus_name();
let mut controls = MediaControls::new(PlatformConfig {
display_name: "Furumi Desktop",
dbus_name: &dbus_name,
hwnd: platform_window(),
})
.ok()?;
controls
.attach(move |event| {
let command = match event {
MediaControlEvent::Toggle => MediaCommand::Toggle,
MediaControlEvent::Play => MediaCommand::Play,
MediaControlEvent::Pause => MediaCommand::Pause,
MediaControlEvent::Next => MediaCommand::Next,
MediaControlEvent::Previous => MediaCommand::Previous,
MediaControlEvent::Stop => MediaCommand::Stop,
_ => return,
};
on_command(command);
})
.ok()?;
Some(Self {
controls,
metadata: None,
playback: None,
last_position_update: None,
})
}
pub fn update_metadata(
&mut self,
title: &str,
artist: &str,
album: &str,
duration_seconds: f64,
) {
let duration_ms = duration_millis(duration_seconds);
let next = (
title.to_owned(),
artist.to_owned(),
album.to_owned(),
duration_ms,
);
if self.metadata.as_ref() == Some(&next) {
return;
}
let _ = self.controls.set_metadata(MediaMetadata {
title: Some(title),
artist: Some(artist),
album: Some(album),
duration: (duration_ms > 0).then(|| Duration::from_millis(duration_ms)),
cover_url: None,
});
self.metadata = Some(next);
}
pub fn update_playback(&mut self, playing: bool, paused: bool, position_seconds: f64) {
let next = (playing, paused);
let state_changed = self.playback != Some(next);
if !state_changed
&& self
.last_position_update
.is_some_and(|last| last.elapsed() < Duration::from_secs(2))
{
return;
}
let progress = Some(MediaPosition(Duration::from_millis(duration_millis(
position_seconds,
))));
let state = if !playing {
MediaPlayback::Stopped
} else if paused {
MediaPlayback::Paused { progress }
} else {
MediaPlayback::Playing { progress }
};
let _ = self.controls.set_playback(state);
self.playback = Some(next);
self.last_position_update = Some(Instant::now());
}
}
impl Drop for MediaSession {
fn drop(&mut self) {
let _ = self.controls.detach();
}
}
fn duration_millis(seconds: f64) -> u64 {
if seconds.is_finite() && seconds > 0.0 {
u64::try_from(Duration::from_secs_f64(seconds).as_millis()).unwrap_or(u64::MAX)
} else {
0
}
}
fn platform_dbus_name() -> String {
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
{
return format!("cy.hexor.furumi.desktop.instance{}", std::process::id());
}
#[allow(unreachable_code)]
"cy.hexor.furumi.desktop".into()
}
#[cfg(not(windows))]
fn platform_window() -> Option<*mut std::ffi::c_void> {
None
}
#[cfg(windows)]
fn platform_window() -> Option<*mut std::ffi::c_void> {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
use windows_sys::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, RegisterClassW, WNDCLASSW,
};
unsafe {
let class_name: Vec<u16> = "furumi_desktop_media\0".encode_utf16().collect();
let instance = GetModuleHandleW(std::ptr::null());
let mut class: WNDCLASSW = core::mem::zeroed();
class.lpfnWndProc = Some(DefWindowProcW);
class.hInstance = instance;
class.lpszClassName = class_name.as_ptr();
if RegisterClassW(&class) == 0 {
return None;
}
let window = CreateWindowExW(
0,
class_name.as_ptr(),
class_name.as_ptr(),
0,
0,
0,
0,
0,
std::ptr::null_mut(),
std::ptr::null_mut(),
instance,
std::ptr::null(),
);
(!window.is_null()).then_some(window.cast())
}
}
#[cfg(test)]
mod tests {
use super::duration_millis;
#[test]
fn invalid_media_durations_are_safe() {
assert_eq!(duration_millis(f64::NAN), 0);
assert_eq!(duration_millis(-1.0), 0);
assert_eq!(duration_millis(1.25), 1_250);
}
}
+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,
}
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.97.0"
profile = "minimal"
components = ["clippy", "rustfmt"]
+2
View File
@@ -0,0 +1,2 @@
edition = "2024"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

+38
View File
@@ -0,0 +1,38 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rustup
pkg-config
cmake
clang
mold
];
buildInputs = with pkgs; [
fontconfig
freetype
libxkbcommon
wayland
vulkan-loader
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [
fontconfig
freetype
libxkbcommon
wayland
vulkan-loader
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
]);
RUST_BACKTRACE = "1";
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "block"
version = "0.1.6"
authors = ["Steven Sheldon"]
description = "Rust interface for Apple's C language extension of blocks."
keywords = ["blocks", "osx", "ios", "objective-c"]
readme = "README.md"
repository = "http://github.com/SSheldon/rust-block"
documentation = "http://ssheldon.github.io/rust-objc/block/"
license = "MIT"
exclude = [
".gitignore",
".travis.yml",
"travis_install.sh",
"travis_test.sh",
"tests-ios/**",
]
[dev-dependencies.objc_test_utils]
version = "0.0"
path = "test_utils"
+400
View File
@@ -0,0 +1,400 @@
/*!
A Rust interface for Objective-C blocks.
For more information on the specifics of the block implementation, see
Clang's documentation: http://clang.llvm.org/docs/Block-ABI-Apple.html
# Invoking blocks
The `Block` struct is used for invoking blocks from Objective-C. For example,
consider this Objective-C function:
``` objc
int32_t sum(int32_t (^block)(int32_t, int32_t)) {
return block(5, 8);
}
```
We could write it in Rust as the following:
```
# use block::Block;
unsafe fn sum(block: &Block<(i32, i32), i32>) -> i32 {
block.call((5, 8))
}
```
Note the extra parentheses in the `call` method, since the arguments must be
passed as a tuple.
# Creating blocks
Creating a block to pass to Objective-C can be done with the `ConcreteBlock`
struct. For example, to create a block that adds two `i32`s, we could write:
```
# use block::ConcreteBlock;
let block = ConcreteBlock::new(|a: i32, b: i32| a + b);
let block = block.copy();
assert!(unsafe { block.call((5, 8)) } == 13);
```
It is important to copy your block to the heap (with the `copy` method) before
passing it to Objective-C; this is because our `ConcreteBlock` is only meant
to be copied once, and we can enforce this in Rust, but if Objective-C code
were to copy it twice we could have a double free.
*/
#[cfg(test)]
mod test_utils;
use std::marker::PhantomData;
use std::mem;
use std::ops::{Deref, DerefMut};
use std::os::raw::{c_int, c_ulong, c_void};
use std::ptr;
#[repr(C)]
struct Class {
_private: [u8; 0],
}
#[cfg_attr(any(target_os = "macos", target_os = "ios"),
link(name = "System", kind = "dylib"))]
#[cfg_attr(not(any(target_os = "macos", target_os = "ios")),
link(name = "BlocksRuntime", kind = "dylib"))]
extern "C" {
static _NSConcreteStackBlock: Class;
fn _Block_copy(block: *const c_void) -> *mut c_void;
fn _Block_release(block: *const c_void);
}
/// Types that may be used as the arguments to an Objective-C block.
pub trait BlockArguments: Sized {
/// Calls the given `Block` with self as the arguments.
///
/// Unsafe because `block` must point to a valid `Block` and this invokes
/// foreign code whose safety the compiler cannot verify.
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R;
}
macro_rules! block_args_impl {
($($a:ident : $t:ident),*) => (
impl<$($t),*> BlockArguments for ($($t,)*) {
unsafe fn call_block<R>(self, block: *mut Block<Self, R>) -> R {
let invoke: unsafe extern "C" fn(*mut Block<Self, R> $(, $t)*) -> R = {
let base = block as *mut BlockBase<Self, R>;
mem::transmute((*base).invoke)
};
let ($($a,)*) = self;
invoke(block $(, $a)*)
}
}
);
}
block_args_impl!();
block_args_impl!(a: A);
block_args_impl!(a: A, b: B);
block_args_impl!(a: A, b: B, c: C);
block_args_impl!(a: A, b: B, c: C, d: D);
block_args_impl!(a: A, b: B, c: C, d: D, e: E);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
block_args_impl!(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
#[repr(C)]
struct BlockBase<A, R> {
isa: *const Class,
flags: c_int,
_reserved: c_int,
invoke: unsafe extern "C" fn(*mut Block<A, R>, ...) -> R,
}
/// An Objective-C block that takes arguments of `A` when called and
/// returns a value of `R`.
#[repr(C)]
pub struct Block<A, R> {
_base: PhantomData<BlockBase<A, R>>,
}
impl<A: BlockArguments, R> Block<A, R> where A: BlockArguments {
/// Call self with the given arguments.
///
/// Unsafe because this invokes foreign code that the caller must verify
/// doesn't violate any of Rust's safety rules. For example, if this block
/// is shared with multiple references, the caller must ensure that calling
/// it will not cause a data race.
pub unsafe fn call(&self, args: A) -> R {
args.call_block(self as *const _ as *mut _)
}
}
/// A reference-counted Objective-C block.
pub struct RcBlock<A, R> {
ptr: *mut Block<A, R>,
}
impl<A, R> RcBlock<A, R> {
/// Construct an `RcBlock` for the given block without copying it.
/// The caller must ensure the block has a +1 reference count.
///
/// Unsafe because `ptr` must point to a valid `Block` and must have a +1
/// reference count or it will be overreleased when the `RcBlock` is
/// dropped.
pub unsafe fn new(ptr: *mut Block<A, R>) -> Self {
RcBlock { ptr: ptr }
}
/// Constructs an `RcBlock` by copying the given block.
///
/// Unsafe because `ptr` must point to a valid `Block`.
pub unsafe fn copy(ptr: *mut Block<A, R>) -> Self {
let ptr = _Block_copy(ptr as *const c_void) as *mut Block<A, R>;
RcBlock { ptr: ptr }
}
}
impl<A, R> Clone for RcBlock<A, R> {
fn clone(&self) -> RcBlock<A, R> {
unsafe {
RcBlock::copy(self.ptr)
}
}
}
impl<A, R> Deref for RcBlock<A, R> {
type Target = Block<A, R>;
fn deref(&self) -> &Block<A, R> {
unsafe { &*self.ptr }
}
}
impl<A, R> Drop for RcBlock<A, R> {
fn drop(&mut self) {
unsafe {
_Block_release(self.ptr as *const c_void);
}
}
}
/// Types that may be converted into a `ConcreteBlock`.
pub trait IntoConcreteBlock<A>: Sized where A: BlockArguments {
/// The return type of the resulting `ConcreteBlock`.
type Ret;
/// Consumes self to create a `ConcreteBlock`.
fn into_concrete_block(self) -> ConcreteBlock<A, Self::Ret, Self>;
}
macro_rules! concrete_block_impl {
($f:ident) => (
concrete_block_impl!($f,);
);
($f:ident, $($a:ident : $t:ident),*) => (
impl<$($t,)* R, X> IntoConcreteBlock<($($t,)*)> for X
where X: Fn($($t,)*) -> R {
type Ret = R;
fn into_concrete_block(self) -> ConcreteBlock<($($t,)*), R, X> {
unsafe extern "C" fn $f<$($t,)* R, X>(
block_ptr: *mut ConcreteBlock<($($t,)*), R, X>
$(, $a: $t)*) -> R
where X: Fn($($t,)*) -> R {
let block = &*block_ptr;
(block.closure)($($a),*)
}
let f: unsafe extern "C" fn(*mut ConcreteBlock<($($t,)*), R, X> $(, $a: $t)*) -> R = $f;
unsafe {
ConcreteBlock::with_invoke(mem::transmute(f), self)
}
}
}
);
}
concrete_block_impl!(concrete_block_invoke_args0);
concrete_block_impl!(concrete_block_invoke_args1, a: A);
concrete_block_impl!(concrete_block_invoke_args2, a: A, b: B);
concrete_block_impl!(concrete_block_invoke_args3, a: A, b: B, c: C);
concrete_block_impl!(concrete_block_invoke_args4, a: A, b: B, c: C, d: D);
concrete_block_impl!(concrete_block_invoke_args5, a: A, b: B, c: C, d: D, e: E);
concrete_block_impl!(concrete_block_invoke_args6, a: A, b: B, c: C, d: D, e: E, f: F);
concrete_block_impl!(concrete_block_invoke_args7, a: A, b: B, c: C, d: D, e: E, f: F, g: G);
concrete_block_impl!(concrete_block_invoke_args8, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H);
concrete_block_impl!(concrete_block_invoke_args9, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I);
concrete_block_impl!(concrete_block_invoke_args10, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J);
concrete_block_impl!(concrete_block_invoke_args11, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K);
concrete_block_impl!(concrete_block_invoke_args12, a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H, i: I, j: J, k: K, l: L);
/// An Objective-C block whose size is known at compile time and may be
/// constructed on the stack.
#[repr(C)]
pub struct ConcreteBlock<A, R, F> {
base: BlockBase<A, R>,
descriptor: Box<BlockDescriptor<ConcreteBlock<A, R, F>>>,
closure: F,
}
impl<A, R, F> ConcreteBlock<A, R, F>
where A: BlockArguments, F: IntoConcreteBlock<A, Ret=R> {
/// Constructs a `ConcreteBlock` with the given closure.
/// When the block is called, it will return the value that results from
/// calling the closure.
pub fn new(closure: F) -> Self {
closure.into_concrete_block()
}
}
impl<A, R, F> ConcreteBlock<A, R, F> {
/// Constructs a `ConcreteBlock` with the given invoke function and closure.
/// Unsafe because the caller must ensure the invoke function takes the
/// correct arguments.
unsafe fn with_invoke(invoke: unsafe extern "C" fn(*mut Self, ...) -> R,
closure: F) -> Self {
ConcreteBlock {
base: BlockBase {
isa: &_NSConcreteStackBlock,
// 1 << 25 = BLOCK_HAS_COPY_DISPOSE
flags: 1 << 25,
_reserved: 0,
invoke: mem::transmute(invoke),
},
descriptor: Box::new(BlockDescriptor::new()),
closure: closure,
}
}
}
impl<A, R, F> ConcreteBlock<A, R, F> where F: 'static {
/// Copy self onto the heap as an `RcBlock`.
pub fn copy(self) -> RcBlock<A, R> {
unsafe {
let mut block = self;
let copied = RcBlock::copy(&mut *block);
// At this point, our copy helper has been run so the block will
// be moved to the heap and we can forget the original block
// because the heap block will drop in our dispose helper.
mem::forget(block);
copied
}
}
}
impl<A, R, F> Clone for ConcreteBlock<A, R, F> where F: Clone {
fn clone(&self) -> Self {
unsafe {
ConcreteBlock::with_invoke(mem::transmute(self.base.invoke),
self.closure.clone())
}
}
}
impl<A, R, F> Deref for ConcreteBlock<A, R, F> {
type Target = Block<A, R>;
fn deref(&self) -> &Block<A, R> {
unsafe { &*(&self.base as *const _ as *const Block<A, R>) }
}
}
impl<A, R, F> DerefMut for ConcreteBlock<A, R, F> {
fn deref_mut(&mut self) -> &mut Block<A, R> {
unsafe { &mut *(&mut self.base as *mut _ as *mut Block<A, R>) }
}
}
unsafe extern "C" fn block_context_dispose<B>(block: &mut B) {
// Read the block onto the stack and let it drop
ptr::read(block);
}
unsafe extern "C" fn block_context_copy<B>(_dst: &mut B, _src: &B) {
// The runtime memmoves the src block into the dst block, nothing to do
}
#[repr(C)]
struct BlockDescriptor<B> {
_reserved: c_ulong,
block_size: c_ulong,
copy_helper: unsafe extern "C" fn(&mut B, &B),
dispose_helper: unsafe extern "C" fn(&mut B),
}
impl<B> BlockDescriptor<B> {
fn new() -> BlockDescriptor<B> {
BlockDescriptor {
_reserved: 0,
block_size: mem::size_of::<B>() as c_ulong,
copy_helper: block_context_copy::<B>,
dispose_helper: block_context_dispose::<B>,
}
}
}
#[cfg(test)]
mod tests {
use test_utils::*;
use super::{ConcreteBlock, RcBlock};
#[test]
fn test_call_block() {
let block = get_int_block_with(13);
unsafe {
assert!(block.call(()) == 13);
}
}
#[test]
fn test_call_block_args() {
let block = get_add_block_with(13);
unsafe {
assert!(block.call((2,)) == 15);
}
}
#[test]
fn test_create_block() {
let block = ConcreteBlock::new(|| 13);
let result = invoke_int_block(&block);
assert!(result == 13);
}
#[test]
fn test_create_block_args() {
let block = ConcreteBlock::new(|a: i32| a + 5);
let result = invoke_add_block(&block, 6);
assert!(result == 11);
}
#[test]
fn test_concrete_block_copy() {
let s = "Hello!".to_string();
let expected_len = s.len() as i32;
let block = ConcreteBlock::new(move || s.len() as i32);
assert!(invoke_int_block(&block) == expected_len);
let copied = block.copy();
assert!(invoke_int_block(&copied) == expected_len);
}
#[test]
fn test_concrete_block_stack_copy() {
fn make_block() -> RcBlock<(), i32> {
let x = 7;
let block = ConcreteBlock::new(move || x);
block.copy()
}
let block = make_block();
assert!(invoke_int_block(&block) == 7);
}
}