Init commit
@@ -0,0 +1,4 @@
|
|||||||
|
/target/
|
||||||
|
/.direnv/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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" }
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Furumi Desktop
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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).
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let backend = furumi_backend::spawn_backend()?;
|
||||||
|
furumi_ui::run(&backend)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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(),
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 {}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "furumi-domain"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
slint_build::compile("ui/app.slint").expect("failed to compile Slint UI");
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.97.0"
|
||||||
|
profile = "minimal"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1012 KiB |
@@ -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";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||