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);
|
||||
}
|
||||
}
|
||||
|
||||