Fixed UI bugs
This commit is contained in:
+47
-7
@@ -65,10 +65,50 @@ the common placeholder and never performs filesystem or network I/O.
|
||||
|
||||
## 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.
|
||||
The backend stores settings in `furumi-desktop.sqlite3` under the dedicated
|
||||
`furumi-desktop` platform application-data directory selected by
|
||||
`directories::ProjectDirs`. Device identity, federation state, and caches use
|
||||
that same desktop-specific namespace and are never shared with other Furumi
|
||||
clients. Only the music library path returned by `furumi-library` is shared.
|
||||
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.
|
||||
|
||||
## Device pairing and sync groups
|
||||
|
||||
A pairing request includes the requester's sync-group ID, active-device count,
|
||||
and known device profiles. A requester that is the only active device can move
|
||||
to the inviter's group through the normal accept flow. If it already belongs to
|
||||
a different group with multiple active devices, the inviter must explicitly
|
||||
choose either to join that existing group or keep the local group and move only
|
||||
the requesting device into it. Joining imports the requester's group profiles;
|
||||
keeping the local group may leave the requester's former peers unable to sync
|
||||
with that device. The backend never resolves this conflict without a user
|
||||
choice.
|
||||
|
||||
The default music directory is derived from the parent directory of the shared
|
||||
`furumi-library` database and named `federation-media`. This keeps the default
|
||||
platform-correct (`$XDG_DATA_HOME/furumi/federation-media` on Linux) and shared
|
||||
with other Furumi clients. The desktop-specific federation image and temporary
|
||||
stream caches remain under the desktop cache directory. A user-selected Library
|
||||
Path replaces only the permanent music directory and is never overwritten by
|
||||
default-path migrations.
|
||||
|
||||
## Listening history and similarity
|
||||
|
||||
Qualified listening sessions are append-only `ListenRecorded` operations in
|
||||
the trusted-device sync log. Desktop records its own finished, skipped,
|
||||
stopped, and replaced sessions using the same wire contract as TUI, then
|
||||
projects `furumi-library`'s materialized history into a dedicated screen.
|
||||
Unknown remote content is resolved asynchronously by content ID so metadata
|
||||
and artwork can be enriched without blocking the UI.
|
||||
|
||||
Similarity is disabled by default. When enabled, the backend downloads the
|
||||
selected versioned ONNX model, stores normalized embeddings in the shared
|
||||
music database, and keeps an exact in-memory index for local queries. Network
|
||||
queries additionally require explicit privacy consent. Compatible peers are
|
||||
discovered through the signed similarity-routing DHT with connected/known
|
||||
peers as fallback; only anonymous numeric embeddings with an exact profile
|
||||
fingerprint are exchanged.
|
||||
|
||||
Generated
+616
-6
@@ -184,6 +184,12 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "anymap3"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -526,7 +532,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"pastey 0.1.1",
|
||||
"rayon",
|
||||
"thiserror 2.0.20",
|
||||
"v_frame",
|
||||
@@ -615,6 +621,24 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
@@ -1435,6 +1459,17 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive-new"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -1580,6 +1615,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -1638,6 +1679,18 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-eq"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-hash"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
@@ -1659,7 +1712,7 @@ dependencies = [
|
||||
"ed25519",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2",
|
||||
"sha2 0.11.0",
|
||||
"signature",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -1756,6 +1809,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -1983,6 +2047,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "float-ord"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -2122,17 +2192,23 @@ name = "furumi-backend"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"directories",
|
||||
"furumi-backend-api",
|
||||
"furumi-domain",
|
||||
"furumi-library",
|
||||
"futures-util",
|
||||
"music-dht",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
"rusqlite",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tract-onnx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2593,6 +2669,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
@@ -2635,6 +2712,8 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2867,6 +2946,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3534,6 +3614,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-lifetimes"
|
||||
version = "1.0.11"
|
||||
@@ -3602,7 +3691,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"portmapper",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
@@ -3717,7 +3806,7 @@ dependencies = [
|
||||
"pin-project",
|
||||
"postcard",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
@@ -4200,6 +4289,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -4209,6 +4304,16 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
@@ -4234,6 +4339,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.7.1"
|
||||
@@ -4252,6 +4363,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -4406,6 +4527,21 @@ version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c"
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -4607,6 +4743,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom-language"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
|
||||
dependencies = [
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
@@ -5407,6 +5552,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
@@ -5608,6 +5759,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portmapper"
|
||||
version = "0.19.1"
|
||||
@@ -5709,6 +5869,15 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@@ -5756,6 +5925,29 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
@@ -5828,6 +6020,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.20",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -5925,6 +6173,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.10.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -6011,6 +6269,12 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
@@ -6125,6 +6389,47 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -6158,7 +6463,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -6314,6 +6619,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.37.28"
|
||||
@@ -6459,6 +6778,19 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -6468,6 +6800,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scan_fmt"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -6636,6 +6977,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.4.3"
|
||||
@@ -6663,6 +7016,17 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
@@ -7120,6 +7484,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
@@ -7129,6 +7499,16 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -7916,6 +8296,217 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-core"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"anymap3",
|
||||
"bit-set",
|
||||
"derive-new",
|
||||
"downcast-rs 2.0.2",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pastey 0.2.3",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"tract-data",
|
||||
"tract-linalg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-data"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"downcast-rs 2.0.2",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"inventory",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"nom 8.0.0",
|
||||
"nom-language",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"scan_fmt",
|
||||
"smallvec",
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-extra"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6"
|
||||
dependencies = [
|
||||
"tract-nnef",
|
||||
"tract-pulse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-hir"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d"
|
||||
dependencies = [
|
||||
"derive-new",
|
||||
"log",
|
||||
"tract-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-linalg"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cc",
|
||||
"derive-new",
|
||||
"downcast-rs 2.0.2",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"minijinja",
|
||||
"num-traits",
|
||||
"pastey 0.2.3",
|
||||
"scan_fmt",
|
||||
"tract-data",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-nnef"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"erased-serde",
|
||||
"flate2",
|
||||
"log",
|
||||
"minijinja",
|
||||
"nom 8.0.0",
|
||||
"nom-language",
|
||||
"safetensors",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simd-adler32",
|
||||
"tar",
|
||||
"tract-core",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"derive-new",
|
||||
"dyn-eq",
|
||||
"log",
|
||||
"memmap2",
|
||||
"num-integer",
|
||||
"prost",
|
||||
"smallvec",
|
||||
"tract-extra",
|
||||
"tract-hir",
|
||||
"tract-nnef",
|
||||
"tract-onnx-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e"
|
||||
dependencies = [
|
||||
"dyn-eq",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"rand 0.10.2",
|
||||
"rand_distr",
|
||||
"rustfft",
|
||||
"tract-extra",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2"
|
||||
dependencies = [
|
||||
"downcast-rs 2.0.2",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"serde",
|
||||
"tract-pulse-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987"
|
||||
dependencies = [
|
||||
"downcast-rs 2.0.2",
|
||||
"dyn-eq",
|
||||
"lazy_static",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-transformers"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5"
|
||||
dependencies = [
|
||||
"float-ord",
|
||||
"rayon",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -7941,6 +8532,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -8285,6 +8882,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -8305,7 +8915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"downcast-rs 1.2.1",
|
||||
"rustix 1.1.4",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
|
||||
+7
-1
@@ -11,7 +11,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
license = "WTFPL"
|
||||
@@ -21,6 +21,8 @@ readme = "README.md"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.102"
|
||||
blake3 = "1"
|
||||
futures-util = "0.3.32"
|
||||
slint = { version = "=1.17.1", default-features = false, features = [
|
||||
"backend-winit",
|
||||
"renderer-femtovg",
|
||||
@@ -47,6 +49,10 @@ rodio = { version = "0.22.2", default-features = false, features = [
|
||||
"symphonia-isomp4",
|
||||
"symphonia-alac",
|
||||
] }
|
||||
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
rustfft = "6.4.1"
|
||||
sha2 = "0.10.9"
|
||||
tract-onnx = "0.23.4"
|
||||
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
|
||||
core-foundation = "0.10.1"
|
||||
objc2 = "0.6.4"
|
||||
|
||||
+199
-17
@@ -3,7 +3,7 @@
|
||||
use furumi_backend_api::{
|
||||
BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId,
|
||||
};
|
||||
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
|
||||
use furumi_domain::{ArtistKey, QueueItemId, Release, ReleaseKey, Track, TrackKey};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -19,7 +19,7 @@ pub struct Strings {
|
||||
pub search: &'static str,
|
||||
pub library: &'static str,
|
||||
pub queue: &'static str,
|
||||
pub recently_played: &'static str,
|
||||
pub listening_history: &'static str,
|
||||
pub made_for_listening: &'static str,
|
||||
pub empty_queue: &'static str,
|
||||
pub search_placeholder: &'static str,
|
||||
@@ -31,7 +31,7 @@ pub const EN: Strings = Strings {
|
||||
search: "Search",
|
||||
library: "Your library",
|
||||
queue: "Queue",
|
||||
recently_played: "Recently played",
|
||||
listening_history: "Listening history",
|
||||
made_for_listening: "Made for listening",
|
||||
empty_queue: "Your queue is empty",
|
||||
search_placeholder: "Artists, albums or tracks",
|
||||
@@ -52,6 +52,8 @@ pub enum Screen {
|
||||
Home,
|
||||
Search,
|
||||
Library,
|
||||
History,
|
||||
Similarity,
|
||||
Artist(ArtistKey),
|
||||
Release(ReleaseKey, Option<ArtistKey>),
|
||||
Playlist(i64),
|
||||
@@ -117,6 +119,13 @@ pub enum UiAction {
|
||||
},
|
||||
PlayTrack(TrackKey),
|
||||
PlayQueueItem(QueueItemId),
|
||||
MoveQueueItem {
|
||||
item_id: QueueItemId,
|
||||
target_index: usize,
|
||||
},
|
||||
RemoveQueueItem(QueueItemId),
|
||||
SearchSimilar(TrackKey),
|
||||
ClearSimilarity,
|
||||
PlayContext {
|
||||
tracks: Vec<TrackKey>,
|
||||
selected: TrackKey,
|
||||
@@ -145,6 +154,13 @@ pub enum UiAction {
|
||||
LibraryPathChanged(String),
|
||||
FederationChanged(bool),
|
||||
SaveFederatedOnListenChanged(bool),
|
||||
SimilarityEnabledChanged(bool),
|
||||
SimilarityModelChanged(String),
|
||||
SimilarityProfileChanged(String),
|
||||
SimilarityWorkersChanged(usize),
|
||||
SimilarityMinimumScoreChanged(f32),
|
||||
SimilarityMaxTracksPerArtistChanged(usize),
|
||||
SimilarityFederationConsentChanged(bool),
|
||||
LanguageChanged(String),
|
||||
ShowTrackInfo(TrackKey),
|
||||
CloseTrackInfo,
|
||||
@@ -180,13 +196,15 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
|
||||
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::Artist(key) => {
|
||||
find_artist_name(state, &key).map_or_else(Vec::new, |name| {
|
||||
send(BackendCommand::LoadArtist {
|
||||
request_id,
|
||||
key,
|
||||
name: name.to_owned(),
|
||||
})
|
||||
})
|
||||
}),
|
||||
}
|
||||
Screen::Release(key, preferred) => {
|
||||
find_release(state, &key).map_or_else(Vec::new, |release| {
|
||||
let selected_artist = preferred
|
||||
@@ -198,8 +216,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
|
||||
.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()))
|
||||
find_artist_name(state, preferred)
|
||||
.map(|name| (preferred.clone(), name.to_owned()))
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
@@ -271,6 +289,26 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
|
||||
}
|
||||
UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }),
|
||||
UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }),
|
||||
UiAction::MoveQueueItem {
|
||||
item_id,
|
||||
target_index,
|
||||
} => send(BackendCommand::MoveQueueItem {
|
||||
item_id,
|
||||
target_index,
|
||||
}),
|
||||
UiAction::RemoveQueueItem(item_id) => send(BackendCommand::RemoveQueueItem { item_id }),
|
||||
UiAction::SearchSimilar(track) => {
|
||||
if state.frontend.screen != Screen::Similarity {
|
||||
state
|
||||
.frontend
|
||||
.navigation_history
|
||||
.push(state.frontend.screen.clone());
|
||||
state.frontend.navigation_forward.clear();
|
||||
}
|
||||
state.frontend.screen = Screen::Similarity;
|
||||
send(BackendCommand::SearchSimilar { track })
|
||||
}
|
||||
UiAction::ClearSimilarity => send(BackendCommand::ClearSimilarity),
|
||||
UiAction::PlayContext { tracks, selected } => {
|
||||
send(BackendCommand::PlayContext { tracks, selected })
|
||||
}
|
||||
@@ -362,6 +400,48 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityEnabledChanged(enabled) => {
|
||||
state.backend.settings.similarity.enabled = enabled;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityModelChanged(model) => {
|
||||
state.backend.settings.similarity.model = model;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityProfileChanged(profile) => {
|
||||
state.backend.settings.similarity.profile = profile;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityWorkersChanged(workers) => {
|
||||
state.backend.settings.similarity.workers = workers;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityMinimumScoreChanged(minimum_score) => {
|
||||
state.backend.settings.similarity.minimum_score = minimum_score;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityMaxTracksPerArtistChanged(max_tracks_per_artist) => {
|
||||
state.backend.settings.similarity.max_tracks_per_artist = max_tracks_per_artist;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::SimilarityFederationConsentChanged(federation_consent) => {
|
||||
state.backend.settings.similarity.federation_consent = federation_consent;
|
||||
send(BackendCommand::UpdateSettings(
|
||||
state.backend.settings.clone(),
|
||||
))
|
||||
}
|
||||
UiAction::LanguageChanged(language) => {
|
||||
state.frontend.locale = Locale::En;
|
||||
state.backend.settings.language = language;
|
||||
@@ -384,15 +464,64 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_artist<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a furumi_domain::Artist> {
|
||||
match &state.backend.library {
|
||||
fn find_artist_name<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a str> {
|
||||
let library = match &state.backend.library {
|
||||
furumi_backend_api::RemoteData::Ready(l) => Some(l),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(artist) = library
|
||||
.into_iter()
|
||||
.flat_map(|library| library.artists.iter())
|
||||
.chain(state.backend.search.results.artists.iter())
|
||||
.find(|artist| &artist.key == key)
|
||||
{
|
||||
return Some(artist.name.as_str());
|
||||
}
|
||||
.into_iter()
|
||||
.flat_map(|l| l.artists.iter())
|
||||
.chain(state.backend.search.results.artists.iter())
|
||||
.find(|a| &a.key == key)
|
||||
if let Some(name) = library
|
||||
.into_iter()
|
||||
.flat_map(|library| library.featured_releases.iter())
|
||||
.chain(state.backend.search.results.releases.iter())
|
||||
.find_map(|release| artist_name_in_release(release, key))
|
||||
{
|
||||
return Some(name);
|
||||
}
|
||||
library
|
||||
.into_iter()
|
||||
.flat_map(|library| {
|
||||
library.recently_played.iter().chain(
|
||||
library
|
||||
.playlists
|
||||
.iter()
|
||||
.flat_map(|playlist| playlist.tracks.iter()),
|
||||
)
|
||||
})
|
||||
.chain(state.backend.search.results.tracks.iter())
|
||||
.chain(state.backend.queue.items().iter().map(|item| &item.track))
|
||||
.find_map(|track| artist_name_in_track(track, key))
|
||||
}
|
||||
|
||||
fn artist_name_in_release<'a>(release: &'a Release, key: &ArtistKey) -> Option<&'a str> {
|
||||
release
|
||||
.artists
|
||||
.iter()
|
||||
.chain(release.featured_artists.iter())
|
||||
.find(|artist| &artist.key == key)
|
||||
.map(|artist| artist.name.as_str())
|
||||
.or_else(|| {
|
||||
release
|
||||
.tracks
|
||||
.iter()
|
||||
.find_map(|track| artist_name_in_track(track, key))
|
||||
})
|
||||
}
|
||||
|
||||
fn artist_name_in_track<'a>(track: &'a Track, key: &ArtistKey) -> Option<&'a str> {
|
||||
track
|
||||
.artists
|
||||
.iter()
|
||||
.chain(track.featured_artists.iter())
|
||||
.find(|artist| &artist.key == key)
|
||||
.map(|artist| artist.name.as_str())
|
||||
}
|
||||
|
||||
fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> {
|
||||
@@ -516,6 +645,21 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_action_opens_results_and_starts_the_backend_search() {
|
||||
let mut state = AppState::default();
|
||||
let key = TrackKey::local(furumi_domain::LocalTrackId::new(42));
|
||||
|
||||
let effects = reduce_action(&mut state, UiAction::SearchSimilar(key.clone()));
|
||||
|
||||
assert_eq!(state.frontend.screen, Screen::Similarity);
|
||||
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
|
||||
assert_eq!(
|
||||
effects,
|
||||
vec![Effect::Send(BackendCommand::SearchSimilar { track: key })]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_backend_snapshots_are_ignored() {
|
||||
let mut state = AppState::default();
|
||||
@@ -563,6 +707,44 @@ mod tests {
|
||||
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn featured_artist_reference_opens_the_standard_artist_detail() {
|
||||
let mut state = AppState::default();
|
||||
let artist_key = ArtistKey::Federation {
|
||||
peer_id: "peer-a".into(),
|
||||
id: "guest-artist".into(),
|
||||
};
|
||||
state.backend.search.results.releases = vec![Release {
|
||||
key: ReleaseKey::Local(ReleaseId::new(7)),
|
||||
source: CatalogSource::Local,
|
||||
title: "Release".into(),
|
||||
artists: Vec::new(),
|
||||
featured_artists: vec![ArtistRef {
|
||||
key: artist_key.clone(),
|
||||
name: "Guest Artist".into(),
|
||||
}],
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
artwork: Artwork::default(),
|
||||
tracks: Vec::new(),
|
||||
}];
|
||||
|
||||
let effects = reduce_action(
|
||||
&mut state,
|
||||
UiAction::Navigate(Screen::Artist(artist_key.clone())),
|
||||
);
|
||||
|
||||
assert_eq!(state.frontend.screen, Screen::Artist(artist_key.clone()));
|
||||
assert_eq!(
|
||||
effects,
|
||||
vec![Effect::Send(BackendCommand::LoadArtist {
|
||||
request_id: RequestId::new(1),
|
||||
key: artist_key,
|
||||
name: "Guest Artist".into(),
|
||||
})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_lookup_keeps_the_preferred_artist_name_and_key_together() {
|
||||
let mut state = AppState::default();
|
||||
|
||||
@@ -222,7 +222,7 @@ pub struct BuildInfoSnapshot {
|
||||
pub protocols: Vec<VersionEntrySnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SettingsSnapshot {
|
||||
pub network_id: String,
|
||||
pub device_name: String,
|
||||
@@ -230,6 +230,54 @@ pub struct SettingsSnapshot {
|
||||
pub federation_enabled: bool,
|
||||
pub save_federated_on_listen: bool,
|
||||
pub language: String,
|
||||
pub similarity: SimilaritySettingsSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SimilaritySettingsSnapshot {
|
||||
pub enabled: bool,
|
||||
pub model: String,
|
||||
pub profile: String,
|
||||
pub workers: usize,
|
||||
pub minimum_score: f32,
|
||||
pub max_tracks_per_artist: usize,
|
||||
pub federation_consent: bool,
|
||||
pub active_profile: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SimilaritySettingsSnapshot {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
model: "discogs-effnet-bsdynamic-1".into(),
|
||||
profile: "furumi-full-track-v1".into(),
|
||||
workers: std::thread::available_parallelism()
|
||||
.map_or(1, |count| (count.get() / 2).clamp(1, 4)),
|
||||
minimum_score: 0.70,
|
||||
max_tracks_per_artist: 5,
|
||||
federation_consent: false,
|
||||
active_profile: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SimilaritySettingsSnapshot {
|
||||
#[must_use]
|
||||
pub fn normalized(mut self) -> Self {
|
||||
if self.model.trim().is_empty() {
|
||||
self.model = "discogs-effnet-bsdynamic-1".into();
|
||||
}
|
||||
if self.profile.trim().is_empty() {
|
||||
self.profile = "furumi-full-track-v1".into();
|
||||
}
|
||||
self.workers = self.workers.clamp(1, 16);
|
||||
if !self.minimum_score.is_finite() {
|
||||
self.minimum_score = 0.70;
|
||||
}
|
||||
self.minimum_score = self.minimum_score.clamp(0.0, 1.0);
|
||||
self.max_tracks_per_artist = self.max_tracks_per_artist.clamp(1, 50);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SettingsSnapshot {
|
||||
@@ -237,14 +285,40 @@ impl Default for SettingsSnapshot {
|
||||
Self {
|
||||
network_id: "furumi".into(),
|
||||
device_name: String::new(),
|
||||
library_path: "~/Music/Furumi".into(),
|
||||
// The backend resolves this from the platform's shared Furumi data
|
||||
// directory before publishing its first authoritative snapshot.
|
||||
library_path: String::new(),
|
||||
federation_enabled: true,
|
||||
save_federated_on_listen: true,
|
||||
language: "English".into(),
|
||||
similarity: SimilaritySettingsSnapshot::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SimilarityStatusSnapshot {
|
||||
pub phase: String,
|
||||
pub active_profile: Option<String>,
|
||||
pub target_profile: Option<String>,
|
||||
pub model: String,
|
||||
pub total_tracks: usize,
|
||||
pub completed_tracks: usize,
|
||||
pub failed_tracks: usize,
|
||||
pub stored_vectors: usize,
|
||||
pub stored_bytes: u64,
|
||||
pub current_track: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct SimilaritySearchSnapshot {
|
||||
pub source_title: String,
|
||||
pub results: Vec<Track>,
|
||||
pub pending: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct BackendSnapshot {
|
||||
pub revision: u64,
|
||||
@@ -257,6 +331,8 @@ pub struct BackendSnapshot {
|
||||
pub build_info: BuildInfoSnapshot,
|
||||
pub connected_devices: ConnectedDevicesSnapshot,
|
||||
pub settings: SettingsSnapshot,
|
||||
pub similarity_status: SimilarityStatusSnapshot,
|
||||
pub similarity_search: SimilaritySearchSnapshot,
|
||||
pub playback_error: Option<String>,
|
||||
pub settings_error: Option<String>,
|
||||
}
|
||||
@@ -286,6 +362,17 @@ pub enum BackendCommand {
|
||||
PlayQueueItem {
|
||||
item_id: QueueItemId,
|
||||
},
|
||||
SearchSimilar {
|
||||
track: TrackKey,
|
||||
},
|
||||
ClearSimilarity,
|
||||
MoveQueueItem {
|
||||
item_id: QueueItemId,
|
||||
target_index: usize,
|
||||
},
|
||||
RemoveQueueItem {
|
||||
item_id: QueueItemId,
|
||||
},
|
||||
PlayContext {
|
||||
tracks: Vec<TrackKey>,
|
||||
selected: TrackKey,
|
||||
|
||||
@@ -7,6 +7,8 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
blake3.workspace = true
|
||||
futures-util.workspace = true
|
||||
furumi-backend-api.workspace = true
|
||||
furumi-domain.workspace = true
|
||||
furumi-library.workspace = true
|
||||
@@ -14,6 +16,10 @@ music-dht.workspace = true
|
||||
directories.workspace = true
|
||||
rusqlite.workspace = true
|
||||
rodio.workspace = true
|
||||
reqwest.workspace = true
|
||||
rustfft.workspace = true
|
||||
sha2.workspace = true
|
||||
tract-onnx.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -216,6 +216,7 @@ impl Actor {
|
||||
}
|
||||
urgent_targets.push(previous);
|
||||
}
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.audio.stop();
|
||||
self.device_role = DevicePlaybackRole::Control;
|
||||
self.active_device_id.clone_from(&target.id);
|
||||
|
||||
@@ -12,10 +12,11 @@ use std::time::Duration;
|
||||
use anyhow::{Context as _, Result};
|
||||
use music_dht::device_sync::{
|
||||
DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire,
|
||||
PlaybackCommand, PlaybackSnapshot, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist,
|
||||
SnapshotPlaylistItem, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload,
|
||||
SyncOpWire, SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response,
|
||||
finish_send, hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg,
|
||||
ListenEndReason, ListenEvent, ListenTrackMetadata, PlaybackCommand, PlaybackSnapshot,
|
||||
SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, SnapshotPlaylistItem,
|
||||
SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, SyncOpWire,
|
||||
SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, finish_send,
|
||||
hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg,
|
||||
};
|
||||
use music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
|
||||
use rusqlite::{Connection, OptionalExtension as _, params};
|
||||
@@ -120,6 +121,10 @@ impl DeviceSync {
|
||||
Ok((identity.device_id, identity.name))
|
||||
}
|
||||
|
||||
pub fn new_listen_id() -> String {
|
||||
format!("{}-{}", now_ms(), random_hex(12))
|
||||
}
|
||||
|
||||
pub fn set_device_name(&self, name: &str, endpoint_ticket: Option<&str>) -> Result<()> {
|
||||
let name = if name.trim().is_empty() {
|
||||
"furumi".to_string()
|
||||
@@ -367,6 +372,56 @@ impl DeviceSync {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn record_listen(
|
||||
&self,
|
||||
listen_id: String,
|
||||
track: &furumi_domain::Track,
|
||||
started_at_ms: i64,
|
||||
listened_ms: i64,
|
||||
ended_reason: ListenEndReason,
|
||||
) -> Result<()> {
|
||||
let Some(content_id) = track
|
||||
.key
|
||||
.content_id()
|
||||
.and_then(|id| music_dht::normalize_content_id(id.as_str()))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut artist_names = track
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| artist.name.clone())
|
||||
.filter(|artist| !artist.trim().is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if artist_names.is_empty() && !track.artist.trim().is_empty() {
|
||||
artist_names.push(track.artist.clone());
|
||||
}
|
||||
let event = ListenEvent {
|
||||
listen_id,
|
||||
content_id,
|
||||
started_at_ms,
|
||||
listened_ms: listened_ms.max(0),
|
||||
track_duration_ms: (track.duration_seconds > 0.0).then_some(
|
||||
crate::support::seconds_to_milliseconds(track.duration_seconds),
|
||||
),
|
||||
ended_reason,
|
||||
track: ListenTrackMetadata {
|
||||
title: track.title.clone(),
|
||||
artist_names,
|
||||
featured_artist_names: track
|
||||
.featured_artists
|
||||
.iter()
|
||||
.map(|artist| artist.name.clone())
|
||||
.collect(),
|
||||
release_title: (!track.release.trim().is_empty()).then(|| track.release.clone()),
|
||||
},
|
||||
};
|
||||
if event.should_record() {
|
||||
self.record_op(SyncOpPayload::ListenRecorded { event })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn record_playlist_created(&self, id: i64, title: &str) -> Result<()> {
|
||||
let playlist_id = self.library.ensure_playlist_sync_id(id)?;
|
||||
self.record_op(SyncOpPayload::PlaylistCreated {
|
||||
@@ -698,6 +753,8 @@ async fn handle_pair_request(
|
||||
finish_response(&mut stream, RESPONSE_DRAIN).await?;
|
||||
return Ok(());
|
||||
}
|
||||
let requester_group_id = requester_group_id.filter(|group| !group.trim().is_empty());
|
||||
let requester_group_active_devices = requester_group_active_devices.max(1);
|
||||
let devices_json = serde_json::to_string(&requester_devices)?;
|
||||
lock(&sync.conn).execute(
|
||||
"INSERT OR IGNORE INTO sync_pending_pairing
|
||||
@@ -1016,6 +1073,15 @@ fn pair_request_id(invite_id: &str, device_id: &str) -> String {
|
||||
format!("pair_{}", &digest[..16])
|
||||
}
|
||||
|
||||
fn requester_group_conflict(
|
||||
local_group_id: &str,
|
||||
requester_group_id: Option<&str>,
|
||||
requester_group_active_devices: usize,
|
||||
) -> bool {
|
||||
requester_group_id.is_some_and(|group| !group.trim().is_empty() && group != local_group_id)
|
||||
&& requester_group_active_devices > 1
|
||||
}
|
||||
|
||||
fn valid_pair_request(
|
||||
sync: &DeviceSync,
|
||||
invite_id: &str,
|
||||
@@ -1115,6 +1181,150 @@ enum PairAttempt {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
fn with_test_sync(test: impl FnOnce(&DeviceSync)) {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
init_schema(&conn).unwrap();
|
||||
let unique = NEXT_TEST_DB.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let library_path = std::env::temp_dir().join(format!(
|
||||
"furumi-desktop-devices-test-{}-{}-{}.sqlite3",
|
||||
std::process::id(),
|
||||
now_ms(),
|
||||
unique
|
||||
));
|
||||
let library = Arc::new(furumi_library::Library::open(&library_path).unwrap());
|
||||
let (events, _event_rx) = tokio::sync::mpsc::channel(4);
|
||||
let sync = DeviceSync {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
library,
|
||||
events,
|
||||
playback: Arc::new(Mutex::new(PlaybackStore::default())),
|
||||
sync_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
sync.ensure_identity().unwrap();
|
||||
|
||||
test(&sync);
|
||||
|
||||
drop(sync);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut path = library_path.as_os_str().to_os_string();
|
||||
path.push(suffix);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_profile(device_id: &str) -> DeviceProfileWire {
|
||||
DeviceProfileWire {
|
||||
device_id: device_id.into(),
|
||||
name: device_id.into(),
|
||||
client_version: CLIENT_VERSION.into(),
|
||||
protocol_version: DEVICE_SYNC_PROTOCOL_VERSION,
|
||||
endpoint_id: String::new(),
|
||||
endpoint_ticket: String::new(),
|
||||
revoked: false,
|
||||
revoke_cutoff_seq: None,
|
||||
updated_at_ms: now_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locally_finished_listen_enters_the_shared_history() {
|
||||
with_test_sync(|sync| {
|
||||
let content_id =
|
||||
furumi_domain::ContentId::parse(format!("b3:{}", "a".repeat(64))).unwrap();
|
||||
let track = furumi_domain::Track {
|
||||
key: furumi_domain::TrackKey::remote(content_id.clone()),
|
||||
title: "Shared listen".into(),
|
||||
artist: "Artist".into(),
|
||||
artists: vec![furumi_domain::ArtistRef {
|
||||
key: furumi_domain::ArtistKey::Federation {
|
||||
peer_id: "peer".into(),
|
||||
id: "artist".into(),
|
||||
},
|
||||
name: "Artist".into(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release: "Release".into(),
|
||||
release_id: furumi_domain::ReleaseKey::Federation {
|
||||
peer_id: "peer".into(),
|
||||
id: "release".into(),
|
||||
},
|
||||
duration_seconds: 180.0,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
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: furumi_domain::AudioSource::Federation {
|
||||
peer_id: "peer".into(),
|
||||
content_id,
|
||||
},
|
||||
};
|
||||
|
||||
sync.record_listen(
|
||||
"desktop-listen".into(),
|
||||
&track,
|
||||
now_ms(),
|
||||
180_000,
|
||||
ListenEndReason::Finished,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let history = sync.library.listen_history(10).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].listen_id, "desktop-listen");
|
||||
assert_eq!(history[0].title, "Shared listen");
|
||||
});
|
||||
}
|
||||
|
||||
fn insert_pending_pairing(sync: &DeviceSync, requester_group_devices: &[DeviceProfileWire]) {
|
||||
lock(&sync.conn)
|
||||
.execute(
|
||||
"INSERT INTO sync_pending_pairing
|
||||
(request_id, device_id, name, client_version, endpoint_id,
|
||||
endpoint_ticket, invite_id, created_at_ms, status,
|
||||
requester_group_id, requester_group_active_devices,
|
||||
requester_group_devices_json)
|
||||
VALUES ('request', 'dev_requester', 'Requester', ?1, '', '',
|
||||
'invite', ?2, 'pending', 'grp_remote', 2, ?3)",
|
||||
params![
|
||||
CLIENT_VERSION,
|
||||
now_ms(),
|
||||
serde_json::to_string(requester_group_devices).unwrap()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn device_known(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
lock(&sync.conn)
|
||||
.query_row(
|
||||
"SELECT 1 FROM sync_devices WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn device_trusted(sync: &DeviceSync, device_id: &str) -> bool {
|
||||
lock(&sync.conn)
|
||||
.query_row(
|
||||
"SELECT trusted_at_ms IS NOT NULL FROM sync_devices WHERE device_id = ?1",
|
||||
[device_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_request_ids_are_stable_and_scoped_to_the_device() {
|
||||
assert_eq!(
|
||||
@@ -1126,4 +1336,43 @@ mod tests {
|
||||
pair_request_id("invite", "device-b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_choice_is_only_required_for_an_existing_different_group() {
|
||||
assert!(requester_group_conflict("grp_local", Some("grp_remote"), 2));
|
||||
assert!(!requester_group_conflict(
|
||||
"grp_local",
|
||||
Some("grp_remote"),
|
||||
1
|
||||
));
|
||||
assert!(!requester_group_conflict("grp_local", Some("grp_local"), 3));
|
||||
assert!(!requester_group_conflict("grp_local", None, 3));
|
||||
assert!(!requester_group_conflict("grp_local", Some(" "), 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairing_choice_either_joins_the_requester_group_or_keeps_the_local_group() {
|
||||
let requester_peer = test_profile("dev_requester_peer");
|
||||
|
||||
with_test_sync(|sync| {
|
||||
let local_group = sync.ensure_identity().unwrap().group_id;
|
||||
insert_pending_pairing(sync, std::slice::from_ref(&requester_peer));
|
||||
|
||||
sync.answer_pairing("request", true, false).unwrap();
|
||||
|
||||
assert_eq!(sync.ensure_identity().unwrap().group_id, local_group);
|
||||
assert!(device_trusted(sync, "dev_requester"));
|
||||
assert!(!device_known(sync, "dev_requester_peer"));
|
||||
});
|
||||
|
||||
with_test_sync(|sync| {
|
||||
insert_pending_pairing(sync, std::slice::from_ref(&requester_peer));
|
||||
|
||||
sync.answer_pairing("request", true, true).unwrap();
|
||||
|
||||
assert_eq!(sync.ensure_identity().unwrap().group_id, "grp_remote");
|
||||
assert!(device_trusted(sync, "dev_requester"));
|
||||
assert!(device_known(sync, "dev_requester_peer"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ impl DeviceSync {
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let pending = {
|
||||
let local_group_id = identity.group_id.as_str();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT request_id, device_id, name, client_version,
|
||||
requester_group_id, requester_group_active_devices
|
||||
@@ -115,14 +116,25 @@ impl DeviceSync {
|
||||
ORDER BY created_at_ms",
|
||||
)?;
|
||||
stmt.query_map([], |row| {
|
||||
let requester_group_id = row.get::<_, Option<String>>(4)?;
|
||||
let requester_group_active_devices =
|
||||
usize::try_from(row.get::<_, i64>(5)?.max(0)).unwrap_or(usize::MAX);
|
||||
let group_conflict = requester_group_conflict(
|
||||
local_group_id,
|
||||
requester_group_id.as_deref(),
|
||||
requester_group_active_devices,
|
||||
);
|
||||
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),
|
||||
requester_group_id: group_conflict.then_some(requester_group_id).flatten(),
|
||||
requester_group_active_devices: if group_conflict {
|
||||
requester_group_active_devices
|
||||
} else {
|
||||
0
|
||||
},
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
@@ -480,8 +492,12 @@ impl DeviceSync {
|
||||
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)?;
|
||||
if self
|
||||
.library
|
||||
.apply_listen_event(event, &op.origin_device_id)?
|
||||
{
|
||||
self.notify_library();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -14,6 +14,8 @@ use furumi_domain::{
|
||||
use music_dht::catalog::{
|
||||
CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse,
|
||||
};
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||
use music_dht::{
|
||||
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
|
||||
RendezvousConfig,
|
||||
@@ -82,29 +84,60 @@ const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
|
||||
pub struct Client {
|
||||
service: Arc<MusicDhtService>,
|
||||
similarity_dht: Arc<SimilarityDht>,
|
||||
media_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
|
||||
pub async fn start(
|
||||
data_dir: PathBuf,
|
||||
media_dir: PathBuf,
|
||||
network: &str,
|
||||
similarity: Arc<crate::similarity::Manager>,
|
||||
) -> Result<Arc<Self>> {
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
tokio::fs::create_dir_all(&media_dir).await?;
|
||||
let similarity_routing_path = data_dir.join("similarity-routing.sqlite3");
|
||||
let config = MusicDhtConfig::builder()
|
||||
.data_dir(data_dir)
|
||||
.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)
|
||||
.schema_independent_stream_protocol(crate::federation_similarity::SIMILARITY_ALPN)
|
||||
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||
.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() {} });
|
||||
let service = Arc::new(service);
|
||||
let similarity_dht = SimilarityDht::open(Arc::clone(&service), similarity_routing_path)
|
||||
.await
|
||||
.context("starting similarity routing overlay")?;
|
||||
let routing_acceptor = service
|
||||
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||
.context("starting similarity routing listener")?;
|
||||
tokio::spawn(Arc::clone(&similarity_dht).serve(routing_acceptor));
|
||||
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||
tokio::spawn(crate::federation_similarity::sync_routes(
|
||||
Arc::clone(&similarity_dht),
|
||||
Arc::clone(&similarity),
|
||||
));
|
||||
let similarity_acceptor = service
|
||||
.stream_acceptor(crate::federation_similarity::SIMILARITY_ALPN)
|
||||
.context("starting similarity listener")?;
|
||||
tokio::spawn(crate::federation_similarity::serve(
|
||||
similarity_acceptor,
|
||||
similarity,
|
||||
service.endpoint_id(),
|
||||
));
|
||||
Ok(Arc::new(Self {
|
||||
service: Arc::new(service),
|
||||
service,
|
||||
similarity_dht,
|
||||
media_dir,
|
||||
}))
|
||||
}
|
||||
@@ -171,6 +204,33 @@ impl Client {
|
||||
Ok((results, stats))
|
||||
}
|
||||
|
||||
pub async fn search_similar(
|
||||
&self,
|
||||
query: crate::similarity::QueryVector,
|
||||
limit: usize,
|
||||
minimum_score: f32,
|
||||
max_tracks_per_artist: usize,
|
||||
) -> Result<Vec<crate::federation_similarity::ScoredTrack>> {
|
||||
let mut hits = crate::federation_similarity::search(
|
||||
Arc::clone(&self.service),
|
||||
Arc::clone(&self.similarity_dht),
|
||||
query,
|
||||
limit,
|
||||
minimum_score,
|
||||
max_tracks_per_artist,
|
||||
)
|
||||
.await?;
|
||||
let mut results = SearchResults {
|
||||
tracks: hits.iter().map(|hit| hit.track.clone()).collect(),
|
||||
..SearchResults::default()
|
||||
};
|
||||
self.fetch_artwork(&mut results).await;
|
||||
for (hit, with_artwork) in hits.iter_mut().zip(results.tracks) {
|
||||
hit.track = with_artwork;
|
||||
}
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -181,11 +241,17 @@ impl Client {
|
||||
.into_iter()
|
||||
.chain(outcome.network_results)
|
||||
.collect::<Vec<_>>();
|
||||
convert_items(&items, own)
|
||||
let mut track = convert_items(&items, own)
|
||||
.tracks
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("no federation peer currently publishes this track")
|
||||
.context("no federation peer currently publishes this track")?;
|
||||
if track.cover_uri.is_none()
|
||||
&& let Some(path) = self.artwork_for_track(&track).await
|
||||
{
|
||||
track.cover_uri = Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
pub async fn publish(&self, specs: Vec<ItemSpec>) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
//! Local-index adapter and DHT-routed peer client for Furumi similarity.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use furumi_domain::{ArtistKey, ArtistRef, AudioSource, ContentId, ReleaseKey, Track, TrackKey};
|
||||
use futures_util::stream::{self, StreamExt as _};
|
||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::{EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor};
|
||||
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const MAX_QUERY_PEERS: usize = 48;
|
||||
const QUERY_CONCURRENCY: usize = 8;
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ROUTE_SYNC_INTERVAL: Duration = Duration::from_secs(30);
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
pub struct ScoredTrack {
|
||||
pub track: Track,
|
||||
pub score: f32,
|
||||
pub embedding_signature: Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
}
|
||||
|
||||
pub async fn serve(mut acceptor: StreamAcceptor, manager: Arc<Manager>, own: EndpointId) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_one(stream, manager, own).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: music_dht::ByteStream,
|
||||
manager: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
) -> Result<()> {
|
||||
let request = wire::read_request(&mut stream).await?;
|
||||
let response = if manager.network_allowed() {
|
||||
let matches = tokio::task::spawn_blocking(move || {
|
||||
manager.search_vector_for_peer(&request.profile_id, &request.vector, request.limit)
|
||||
})
|
||||
.await
|
||||
.context("local similarity task failed")
|
||||
.and_then(|result| result);
|
||||
match matches {
|
||||
Ok(matches) => SimilarityResponse::success(
|
||||
matches
|
||||
.into_iter()
|
||||
.filter_map(|found| {
|
||||
let track = found.track;
|
||||
let hit = SimilarityHit {
|
||||
score: found.score,
|
||||
item_id: hex_encode(
|
||||
ItemId::derive(
|
||||
&own,
|
||||
ItemKind::Track,
|
||||
&format!("track:{}", track.id),
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
title: track.title,
|
||||
artist_names: track
|
||||
.artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
featured_artist_names: track
|
||||
.featured_artists
|
||||
.into_iter()
|
||||
.map(|artist| artist.name)
|
||||
.collect(),
|
||||
year: track.release_year,
|
||||
duration_seconds: Some(
|
||||
crate::support::seconds_to_milliseconds(track.duration_seconds)
|
||||
/ 1_000,
|
||||
),
|
||||
content_id: track.content_id,
|
||||
release_title: Some(track.release_title),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
embedding_signature: Some(found.embedding_signature),
|
||||
};
|
||||
hit.validate().is_ok().then_some(hit)
|
||||
})
|
||||
.collect(),
|
||||
)?,
|
||||
Err(error) => {
|
||||
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SimilarityResponse::refused("similarity federation is disabled or has no privacy consent")?
|
||||
};
|
||||
wire::write_response(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "bounded peer fan-out and ranking policy"
|
||||
)]
|
||||
pub async fn search(
|
||||
service: Arc<MusicDhtService>,
|
||||
routing: Arc<SimilarityDht>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
minimum_score: f32,
|
||||
max_tracks_per_artist: usize,
|
||||
) -> Result<Vec<ScoredTrack>> {
|
||||
let own = service.endpoint_id();
|
||||
let routed = tokio::time::timeout(
|
||||
ROUTING_TIMEOUT,
|
||||
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut peers = routed
|
||||
.into_iter()
|
||||
.filter_map(|ticket| {
|
||||
let owner = ticket.endpoint_id();
|
||||
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||
owner,
|
||||
ticket: Some(ticket),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for owner in service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||
{
|
||||
if owner != own && seen.insert(owner) {
|
||||
peers.push(QueryPeer {
|
||||
owner,
|
||||
ticket: None,
|
||||
});
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
let responses = stream::iter(peers.into_iter().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
async move {
|
||||
tokio::time::timeout(QUERY_TIMEOUT, query_peer(service, peer, &request))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(QUERY_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut hits = responses
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut dedup = HashSet::new();
|
||||
let mut signatures = vec![query_signature];
|
||||
let mut artist_counts = HashMap::<String, usize>::new();
|
||||
let mut results = Vec::new();
|
||||
for (track, score, signature) in hits {
|
||||
if score < minimum_score {
|
||||
break;
|
||||
}
|
||||
if query.source_content_id.as_deref().is_some_and(|source| {
|
||||
track
|
||||
.key
|
||||
.content_id()
|
||||
.is_some_and(|id| id.as_str() == source)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let identity = track
|
||||
.key
|
||||
.content_id()
|
||||
.map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned());
|
||||
if !dedup.insert(identity) {
|
||||
continue;
|
||||
}
|
||||
if signature.is_some_and(|candidate| {
|
||||
signatures.iter().any(|existing| {
|
||||
wire::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = track
|
||||
.artists
|
||||
.first()
|
||||
.map(|artist| music_dht::normalize_name(&artist.name))
|
||||
.unwrap_or_default();
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= max_tracks_per_artist {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = signature {
|
||||
signatures.push(signature);
|
||||
}
|
||||
results.push(ScoredTrack {
|
||||
track,
|
||||
score,
|
||||
embedding_signature: signature,
|
||||
});
|
||||
if results.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
type PeerHits = Vec<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct QueryPeer {
|
||||
owner: EndpointId,
|
||||
ticket: Option<PeerTicket>,
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
peer: QueryPeer,
|
||||
request: &SimilarityRequest,
|
||||
) -> Result<PeerHits> {
|
||||
let owner = peer.owner;
|
||||
let mut stream = match peer.ticket {
|
||||
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||
}?;
|
||||
let response = wire::exchange(&mut stream, request).await?;
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused similarity query: {}",
|
||||
response.error.unwrap_or_default()
|
||||
);
|
||||
Ok(response
|
||||
.hits
|
||||
.into_iter()
|
||||
.filter_map(|hit| hit_to_track(owner, hit))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keeps the routing overlay synchronized with the active durable index.
|
||||
pub async fn sync_routes(routing: Arc<SimilarityDht>, manager: Arc<Manager>) {
|
||||
let mut interval = tokio::time::interval(ROUTE_SYNC_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut published_marker: Option<(String, blake3::Hash)> = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let manager = Arc::clone(&manager);
|
||||
let loaded = tokio::task::spawn_blocking(move || manager.routing_signatures()).await;
|
||||
let Ok(Ok(snapshot)) = loaded else {
|
||||
continue;
|
||||
};
|
||||
let Some((profile_id, signatures)) = snapshot else {
|
||||
if published_marker.take().is_some() {
|
||||
routing.clear_local_signatures();
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
for signature in &signatures {
|
||||
hasher.update(signature);
|
||||
}
|
||||
let marker = (profile_id.clone(), hasher.finalize());
|
||||
if published_marker.as_ref() == Some(&marker) {
|
||||
continue;
|
||||
}
|
||||
if routing
|
||||
.sync_local_signatures(profile_id, signatures)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
published_marker = Some(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_to_track(
|
||||
owner: EndpointId,
|
||||
hit: SimilarityHit,
|
||||
) -> Option<(Track, f32, Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>)> {
|
||||
let peer_id = owner.to_string();
|
||||
let content_id = hit
|
||||
.content_id
|
||||
.as_deref()
|
||||
.and_then(|id| ContentId::parse(id).ok())?;
|
||||
let refs = |names: Vec<String>| {
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| ArtistRef {
|
||||
key: ArtistKey::Federation {
|
||||
peer_id: peer_id.clone(),
|
||||
id: music_dht::normalize_name(&name),
|
||||
},
|
||||
name,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let artists = refs(hit.artist_names);
|
||||
let featured_artists = refs(hit.featured_artist_names);
|
||||
let artist = artists
|
||||
.iter()
|
||||
.map(|artist| artist.name.as_str())
|
||||
.chain(featured_artists.iter().map(|artist| artist.name.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let release = hit.release_title.unwrap_or_default();
|
||||
let score = hit.score;
|
||||
let signature = hit.embedding_signature;
|
||||
Some((
|
||||
Track {
|
||||
key: TrackKey::federation(peer_id.clone(), hit.item_id, Some(content_id.clone())),
|
||||
title: hit.title,
|
||||
artist,
|
||||
artists,
|
||||
featured_artists,
|
||||
release: release.clone(),
|
||||
release_id: ReleaseKey::Federation {
|
||||
peer_id: peer_id.clone(),
|
||||
id: format!("name:{}", music_dht::normalize_name(&release)),
|
||||
},
|
||||
duration_seconds: hit
|
||||
.duration_seconds
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.map_or(0.0, f64::from),
|
||||
track_number: hit.track_number.and_then(|value| u32::try_from(value).ok()),
|
||||
disc_number: hit.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,
|
||||
content_id,
|
||||
},
|
||||
},
|
||||
score,
|
||||
signature,
|
||||
))
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
output.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
output
|
||||
}
|
||||
+415
-18
@@ -13,7 +13,7 @@ use furumi_backend_api::{
|
||||
FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot,
|
||||
PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData,
|
||||
RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot,
|
||||
VersionEntrySnapshot,
|
||||
SimilarityStatusSnapshot, VersionEntrySnapshot,
|
||||
};
|
||||
use furumi_domain::{
|
||||
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId,
|
||||
@@ -26,18 +26,20 @@ mod actor_devices;
|
||||
mod audio;
|
||||
mod devices;
|
||||
mod federation;
|
||||
mod federation_similarity;
|
||||
mod settings;
|
||||
mod similarity;
|
||||
mod streaming;
|
||||
mod support;
|
||||
|
||||
use support::{
|
||||
apply_federated_metadata, expand_tilde, extrapolated_control_position, federation_specs,
|
||||
find_catalog_track, library_snapshot, library_track, local_search_results,
|
||||
apply_federated_metadata, extrapolated_control_position, federated_audio_directory,
|
||||
federation_specs, find_catalog_track, library_snapshot, library_track, local_search_results,
|
||||
merge_release_preserving_local, merge_search_results, normalize_device_name,
|
||||
playback_state_acknowledges_command, portable_playback_placeholder,
|
||||
remote_snapshot_has_authority, runtime_build_info, sanitize_filename, selected_track_position,
|
||||
spawn_settings_worker, track_is_liked, track_to_library_fed, track_to_playback_track,
|
||||
track_to_synced_fed, unix_time_ms, volume_percent,
|
||||
remote_snapshot_has_authority, runtime_build_info, sanitize_filename, seconds_to_milliseconds,
|
||||
selected_track_position, spawn_settings_worker, track_is_liked, track_to_library_fed,
|
||||
track_to_playback_track, track_to_synced_fed, unix_time_ms, volume_percent,
|
||||
};
|
||||
|
||||
use settings::SettingsStore;
|
||||
@@ -46,6 +48,14 @@ const COMMAND_CAPACITY: usize = 64;
|
||||
const INTERNAL_CAPACITY: usize = 32;
|
||||
const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
const CONTROL_POSITION_ACK_TOLERANCE_SECONDS: f64 = 3.0;
|
||||
const DESKTOP_APPLICATION_ID: &str = "furumi-desktop";
|
||||
const SIMILARITY_RESULT_LIMIT: usize = 50;
|
||||
|
||||
type SimilarityCandidate = (
|
||||
Track,
|
||||
f32,
|
||||
Option<[u8; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BackendHandle {
|
||||
@@ -81,20 +91,26 @@ impl BackendHandle {
|
||||
///
|
||||
/// Returns an I/O error when the runtime or its owner thread cannot be created.
|
||||
pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> {
|
||||
let project_dirs = directories::ProjectDirs::from("cy", "hexor", "Furumi")
|
||||
// The music library deliberately keeps using furumi_library::default_db_path()
|
||||
// below. Everything else belongs to this client and must not reuse the TUI's
|
||||
// device identity, settings, federation node, or caches.
|
||||
let project_dirs = directories::ProjectDirs::from("cy", "hexor", DESKTOP_APPLICATION_ID)
|
||||
.ok_or(BackendStartupError::DataDirectoryUnavailable)?;
|
||||
let settings_path = project_dirs.data_local_dir().join("furumi-desktop.sqlite3");
|
||||
let federation_data_dir = project_dirs.data_local_dir().join("federation");
|
||||
let federation_media_dir = project_dirs.cache_dir().join("federation-media");
|
||||
let similarity_model_dir = project_dirs.cache_dir().join("similarity-models");
|
||||
let devices_db_path = project_dirs
|
||||
.data_local_dir()
|
||||
.join("devices")
|
||||
.join("sync.sqlite3");
|
||||
let settings_store = SettingsStore::open(&settings_path)?;
|
||||
let library_db_path =
|
||||
furumi_library::default_db_path().map_err(BackendStartupError::Library)?;
|
||||
let default_library_path = library_db_path.with_file_name("federation-media");
|
||||
let settings_store = SettingsStore::open(&settings_path, &default_library_path)?;
|
||||
let loaded_settings = settings_store.load()?;
|
||||
let library_path = furumi_library::default_db_path().map_err(BackendStartupError::Library)?;
|
||||
let catalog = std::sync::Arc::new(
|
||||
furumi_library::Library::open(&library_path).map_err(BackendStartupError::Library)?,
|
||||
furumi_library::Library::open(&library_db_path).map_err(BackendStartupError::Library)?,
|
||||
);
|
||||
let loaded_library = library_snapshot(&catalog).map_err(BackendStartupError::Library)?;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
@@ -132,6 +148,7 @@ pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> {
|
||||
audio,
|
||||
federation_data_dir,
|
||||
federation_media_dir,
|
||||
similarity_model_dir,
|
||||
devices,
|
||||
}));
|
||||
})?;
|
||||
@@ -218,6 +235,10 @@ enum InternalEvent {
|
||||
keys: Vec<TrackKey>,
|
||||
cover_uri: Option<String>,
|
||||
},
|
||||
HistoryTrackResolved {
|
||||
content_id: String,
|
||||
result: Result<Track, String>,
|
||||
},
|
||||
FederationDebugUpdated(FederationDebugSnapshot),
|
||||
DevicesChanged,
|
||||
DeviceLibraryChanged,
|
||||
@@ -226,6 +247,12 @@ enum InternalEvent {
|
||||
DeviceOperationFinished(Result<DeviceOperationResult, String>),
|
||||
DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot),
|
||||
DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand),
|
||||
SimilarityStatus(similarity::SimilarityStatus),
|
||||
SimilarityProfileActivated(Option<String>),
|
||||
SimilaritySearchFinished {
|
||||
source_title: String,
|
||||
result: Result<Vec<Track>, String>,
|
||||
},
|
||||
}
|
||||
|
||||
enum DeviceOperationResult {
|
||||
@@ -246,8 +273,10 @@ struct Actor {
|
||||
federation: Option<std::sync::Arc<federation::Client>>,
|
||||
federation_data_dir: std::path::PathBuf,
|
||||
federation_media_dir: std::path::PathBuf,
|
||||
similarity: std::sync::Arc<similarity::Manager>,
|
||||
ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>,
|
||||
pending_queue_artwork: HashSet<String>,
|
||||
pending_history_resolutions: HashSet<String>,
|
||||
federation_debug_pending: bool,
|
||||
devices: std::sync::Arc<devices::DeviceSync>,
|
||||
device_service: Option<std::sync::Arc<music_dht::MusicDhtService>>,
|
||||
@@ -256,6 +285,14 @@ struct Actor {
|
||||
active_device_name: String,
|
||||
control_anchor: Option<ControlPlaybackAnchor>,
|
||||
pending_control: Option<PendingControlState>,
|
||||
listen_session: Option<ListenSession>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ListenSession {
|
||||
id: String,
|
||||
track: Track,
|
||||
started_at_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -285,6 +322,7 @@ struct ActorBootstrap {
|
||||
audio: audio::Controller,
|
||||
federation_data_dir: std::path::PathBuf,
|
||||
federation_media_dir: std::path::PathBuf,
|
||||
similarity_model_dir: std::path::PathBuf,
|
||||
devices: std::sync::Arc<devices::DeviceSync>,
|
||||
}
|
||||
|
||||
@@ -307,6 +345,7 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
|
||||
audio,
|
||||
federation_data_dir,
|
||||
federation_media_dir,
|
||||
similarity_model_dir,
|
||||
devices,
|
||||
} = bootstrap;
|
||||
let (settings_tx, settings_rx) = std_mpsc::channel();
|
||||
@@ -336,6 +375,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
|
||||
settings_error,
|
||||
..BackendSnapshot::default()
|
||||
};
|
||||
let similarity = similarity::Manager::new(
|
||||
std::sync::Arc::clone(&catalog),
|
||||
internal_tx.clone(),
|
||||
initial_state.settings.similarity.clone(),
|
||||
similarity_model_dir,
|
||||
);
|
||||
let mut actor = Actor {
|
||||
state: initial_state,
|
||||
library,
|
||||
@@ -349,8 +394,10 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
|
||||
federation: None,
|
||||
federation_data_dir,
|
||||
federation_media_dir,
|
||||
similarity,
|
||||
ephemeral_audio: None,
|
||||
pending_queue_artwork: HashSet::new(),
|
||||
pending_history_resolutions: HashSet::new(),
|
||||
federation_debug_pending: false,
|
||||
devices,
|
||||
device_service: None,
|
||||
@@ -359,7 +406,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
|
||||
active_device_name,
|
||||
control_anchor: None,
|
||||
pending_control: None,
|
||||
listen_session: None,
|
||||
};
|
||||
actor.state.similarity_status = similarity_status_snapshot(&actor.similarity.status());
|
||||
if actor.state.settings.similarity.enabled {
|
||||
actor.similarity.start();
|
||||
}
|
||||
actor.refresh_connected_devices();
|
||||
ActorRuntime {
|
||||
actor,
|
||||
@@ -368,6 +420,22 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn similarity_status_snapshot(status: &similarity::SimilarityStatus) -> SimilarityStatusSnapshot {
|
||||
SimilarityStatusSnapshot {
|
||||
phase: status.phase.label().into(),
|
||||
active_profile: status.active_profile.clone(),
|
||||
target_profile: status.target_profile.clone(),
|
||||
model: status.model.clone(),
|
||||
total_tracks: status.total_tracks,
|
||||
completed_tracks: status.completed_tracks,
|
||||
failed_tracks: status.failed_tracks,
|
||||
stored_vectors: status.stored_vectors,
|
||||
stored_bytes: status.stored_bytes,
|
||||
current_track: status.current_track.clone(),
|
||||
error: status.last_error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_actor(bootstrap: ActorBootstrap) {
|
||||
let ActorRuntime {
|
||||
mut actor,
|
||||
@@ -400,6 +468,7 @@ async fn run_actor(bootstrap: ActorBootstrap) {
|
||||
if let Some((_, token)) = actor.active_search.take() {
|
||||
token.cancel();
|
||||
}
|
||||
actor.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
actor.audio.stop();
|
||||
}
|
||||
|
||||
@@ -416,6 +485,7 @@ impl Actor {
|
||||
let data_dir = self.federation_data_dir.clone();
|
||||
let media_dir = self.federation_media_dir.clone();
|
||||
let network = self.state.settings.network_id.trim().to_owned();
|
||||
let similarity = std::sync::Arc::clone(&self.similarity);
|
||||
let internal = self.internal.clone();
|
||||
self.state.federation_activity = FederationActivitySnapshot {
|
||||
operation: FederationOperation::Idle,
|
||||
@@ -425,7 +495,7 @@ impl Actor {
|
||||
};
|
||||
self.publish();
|
||||
tokio::spawn(async move {
|
||||
let result = federation::Client::start(data_dir, media_dir, &network)
|
||||
let result = federation::Client::start(data_dir, media_dir, &network, similarity)
|
||||
.await
|
||||
.map_err(|error| format!("federation: {error:#}"));
|
||||
let _ = internal
|
||||
@@ -659,6 +729,7 @@ impl Actor {
|
||||
}
|
||||
}
|
||||
BackendCommand::Stop => {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
if self.device_role == DevicePlaybackRole::Active {
|
||||
self.audio.stop();
|
||||
}
|
||||
@@ -725,6 +796,36 @@ impl Actor {
|
||||
self.play_current();
|
||||
}
|
||||
}
|
||||
BackendCommand::MoveQueueItem {
|
||||
item_id,
|
||||
target_index,
|
||||
} => {
|
||||
if self.state.queue.move_item(item_id, target_index) {
|
||||
self.send_control_state(false);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
BackendCommand::RemoveQueueItem { item_id } => {
|
||||
if let Some(removed_current) = self.state.queue.remove_item(item_id) {
|
||||
if removed_current {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.remove_ephemeral_audio();
|
||||
if self.state.queue.current().is_some() {
|
||||
self.play_current();
|
||||
} else {
|
||||
self.audio.stop();
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.state.playback.position_seconds = 0.0;
|
||||
self.state.playback.duration_seconds = 0.0;
|
||||
self.send_control_state(true);
|
||||
self.publish();
|
||||
}
|
||||
} else {
|
||||
self.send_control_state(false);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendCommand::PlayContext { tracks, selected } => {
|
||||
let tracks = self.resolve_tracks(&tracks);
|
||||
if !tracks.is_empty() {
|
||||
@@ -845,6 +946,8 @@ impl Actor {
|
||||
}
|
||||
}
|
||||
BackendCommand::Search { request_id, query } => self.start_search(request_id, query),
|
||||
BackendCommand::SearchSimilar { track } => self.start_similarity_search(&track),
|
||||
BackendCommand::ClearSimilarity => self.similarity.clear(),
|
||||
BackendCommand::CancelSearch { request_id } => self.cancel_search(request_id),
|
||||
BackendCommand::LoadArtist {
|
||||
request_id,
|
||||
@@ -857,11 +960,13 @@ impl Actor {
|
||||
artist_key,
|
||||
artist_name,
|
||||
} => self.load_detail(request_id, artist_key, artist_name, Some(key)),
|
||||
BackendCommand::UpdateSettings(settings) => {
|
||||
BackendCommand::UpdateSettings(mut settings) => {
|
||||
settings.similarity = settings.similarity.normalized();
|
||||
let federation_changed = self.state.settings.federation_enabled
|
||||
!= settings.federation_enabled
|
||||
|| self.state.settings.network_id != settings.network_id;
|
||||
let device_name_changed = self.state.settings.device_name != settings.device_name;
|
||||
let similarity_changed = self.state.settings.similarity != settings.similarity;
|
||||
let pending_device_name = settings.device_name.clone();
|
||||
self.state.settings = settings.clone();
|
||||
self.state.settings_error = None;
|
||||
@@ -878,6 +983,9 @@ impl Actor {
|
||||
self.apply_local_device_name(&pending_device_name);
|
||||
self.schedule_device_name_publish(pending_device_name);
|
||||
}
|
||||
if similarity_changed {
|
||||
self.similarity.apply(&self.state.settings.similarity);
|
||||
}
|
||||
}
|
||||
BackendCommand::Shutdown => {}
|
||||
}
|
||||
@@ -1147,6 +1255,7 @@ impl Actor {
|
||||
});
|
||||
}
|
||||
self.federation = Some(client);
|
||||
self.resolve_history_tracks();
|
||||
self.state.federation_activity.pending = false;
|
||||
self.resolve_queue_artwork();
|
||||
self.refresh_federation_debug();
|
||||
@@ -1223,6 +1332,7 @@ impl Actor {
|
||||
.current()
|
||||
.is_some_and(|item| item.track.key.matches(&key))
|
||||
{
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.state.playback_error = Some(message);
|
||||
self.publish();
|
||||
@@ -1270,6 +1380,7 @@ impl Actor {
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.state.playback_error = Some(message);
|
||||
self.publish();
|
||||
@@ -1299,6 +1410,27 @@ impl Actor {
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
InternalEvent::HistoryTrackResolved { content_id, result } => {
|
||||
self.pending_history_resolutions.remove(&content_id);
|
||||
if let Ok(mut resolved) = result {
|
||||
let mut changed = false;
|
||||
for track in &mut self.library.recently_played {
|
||||
if track
|
||||
.key
|
||||
.content_id()
|
||||
.is_some_and(|id| id.as_str() == content_id)
|
||||
{
|
||||
resolved.liked = track.liked;
|
||||
*track = resolved.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.state.library = RemoteData::Ready(self.library.clone());
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
InternalEvent::FederationDebugUpdated(debug) => {
|
||||
self.federation_debug_pending = false;
|
||||
if self.state.federation_debug != debug {
|
||||
@@ -1311,6 +1443,8 @@ impl Actor {
|
||||
self.library = library.clone();
|
||||
self.state.library = RemoteData::Ready(library);
|
||||
self.reconcile_likes();
|
||||
self.similarity.start();
|
||||
self.resolve_history_tracks();
|
||||
}
|
||||
self.refresh_connected_devices();
|
||||
self.publish();
|
||||
@@ -1338,9 +1472,129 @@ impl Actor {
|
||||
InternalEvent::DevicePlaybackCommand(command) => {
|
||||
self.apply_device_playback_command(command);
|
||||
}
|
||||
InternalEvent::SimilarityStatus(status) => {
|
||||
self.state.similarity_status = similarity_status_snapshot(&status);
|
||||
self.publish();
|
||||
}
|
||||
InternalEvent::SimilarityProfileActivated(active_profile) => {
|
||||
self.state.settings.similarity.active_profile = active_profile;
|
||||
let _ = self.settings.send(self.state.settings.clone());
|
||||
self.publish();
|
||||
}
|
||||
InternalEvent::SimilaritySearchFinished {
|
||||
source_title,
|
||||
result,
|
||||
} => {
|
||||
self.state.similarity_search.pending = false;
|
||||
self.state.similarity_search.source_title = source_title;
|
||||
match result {
|
||||
Ok(mut tracks) => {
|
||||
let liked = self
|
||||
.catalog
|
||||
.liked_content_ids()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(self.catalog.fed_like_ids().unwrap_or_default())
|
||||
.collect::<HashSet<_>>();
|
||||
for track in &mut tracks {
|
||||
track.liked = track_is_liked(track, &liked);
|
||||
}
|
||||
self.state.similarity_search.results = tracks;
|
||||
self.state.similarity_search.error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
self.state.similarity_search.results.clear();
|
||||
self.state.similarity_search.error = Some(error);
|
||||
}
|
||||
}
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_similarity_search(&mut self, key: &TrackKey) {
|
||||
let Some(source) = self.track(key).cloned() else {
|
||||
self.state.similarity_search.error = Some("Track is unavailable".into());
|
||||
self.publish();
|
||||
return;
|
||||
};
|
||||
if !self.state.settings.similarity.enabled {
|
||||
self.state.similarity_search.error =
|
||||
Some("Enable Similarity search in Settings first".into());
|
||||
self.state.similarity_search.results.clear();
|
||||
self.publish();
|
||||
return;
|
||||
}
|
||||
let Some(track_id) = source.key.local_id().map(LocalTrackId::get) else {
|
||||
self.state.similarity_search.error =
|
||||
Some("Similarity search currently starts from a local track".into());
|
||||
self.state.similarity_search.results.clear();
|
||||
self.publish();
|
||||
return;
|
||||
};
|
||||
self.state
|
||||
.similarity_search
|
||||
.source_title
|
||||
.clone_from(&source.title);
|
||||
self.state.similarity_search.results.clear();
|
||||
self.state.similarity_search.error = None;
|
||||
self.state.similarity_search.pending = true;
|
||||
self.publish();
|
||||
let manager = std::sync::Arc::clone(&self.similarity);
|
||||
let federation = self.federation.clone();
|
||||
let settings = self.state.settings.similarity.clone();
|
||||
let internal = self.internal.clone();
|
||||
let source_title = source.title;
|
||||
tokio::spawn(async move {
|
||||
let local = tokio::task::spawn_blocking(move || manager.search_track(track_id, 50))
|
||||
.await
|
||||
.map_err(|error| format!("similarity worker failed: {error}"))
|
||||
.and_then(|result| result.map_err(|error| format!("{error:#}")));
|
||||
let result = match local {
|
||||
Ok((local, query)) => {
|
||||
let mut scored = local
|
||||
.into_iter()
|
||||
.map(|found| {
|
||||
(
|
||||
library_track(found.track, ""),
|
||||
found.score,
|
||||
Some(found.embedding_signature),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if settings.federation_consent
|
||||
&& let Some(federation) = federation
|
||||
&& let Ok(remote) = federation
|
||||
.search_similar(
|
||||
query,
|
||||
50,
|
||||
settings.minimum_score,
|
||||
settings.max_tracks_per_artist,
|
||||
)
|
||||
.await
|
||||
{
|
||||
scored.extend(
|
||||
remote
|
||||
.into_iter()
|
||||
.map(|hit| (hit.track, hit.score, hit.embedding_signature)),
|
||||
);
|
||||
}
|
||||
Ok(rank_similarity_candidates(
|
||||
scored,
|
||||
settings.max_tracks_per_artist,
|
||||
))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let _ = internal
|
||||
.send(InternalEvent::SimilaritySearchFinished {
|
||||
source_title,
|
||||
result,
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_remote_playback_snapshot(
|
||||
&mut self,
|
||||
snapshot: music_dht::device_sync::PlaybackSnapshot,
|
||||
@@ -1378,6 +1632,9 @@ impl Actor {
|
||||
self.pending_control = None;
|
||||
}
|
||||
|
||||
if self.device_role == DevicePlaybackRole::Active {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
}
|
||||
self.device_role = DevicePlaybackRole::Control;
|
||||
self.active_device_id.clone_from(&snapshot.device_id);
|
||||
self.active_device_name.clone_from(&snapshot.device_name);
|
||||
@@ -1445,6 +1702,7 @@ impl Actor {
|
||||
self.publish();
|
||||
}
|
||||
audio::Event::Finished => {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Finished);
|
||||
self.remove_ephemeral_audio();
|
||||
if self.advance_queue(true) {
|
||||
self.play_current();
|
||||
@@ -1456,6 +1714,7 @@ impl Actor {
|
||||
}
|
||||
}
|
||||
audio::Event::Failed(message) => {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.state.playback_error = Some(message);
|
||||
self.publish();
|
||||
@@ -1466,10 +1725,12 @@ impl Actor {
|
||||
fn play_current(&mut self) {
|
||||
self.remove_ephemeral_audio();
|
||||
let Some(track) = self.state.queue.current().map(|item| item.track.clone()) else {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.publish();
|
||||
return;
|
||||
};
|
||||
self.begin_listen(&track);
|
||||
self.state.playback.position_seconds = 0.0;
|
||||
self.state.playback.duration_seconds = track.duration_seconds.max(0.0);
|
||||
self.state.playback_error = None;
|
||||
@@ -1495,6 +1756,46 @@ impl Actor {
|
||||
self.publish();
|
||||
}
|
||||
|
||||
fn begin_listen(&mut self, track: &Track) {
|
||||
if self.device_role != DevicePlaybackRole::Active {
|
||||
return;
|
||||
}
|
||||
if let Some(session) = &mut self.listen_session
|
||||
&& session.track.key.matches(&track.key)
|
||||
{
|
||||
session.track = track.clone();
|
||||
return;
|
||||
}
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Replaced);
|
||||
if track.key.content_id().is_some() {
|
||||
self.listen_session = Some(ListenSession {
|
||||
id: devices::DeviceSync::new_listen_id(),
|
||||
track: track.clone(),
|
||||
started_at_ms: unix_time_ms(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_listen(&mut self, ended_reason: music_dht::device_sync::ListenEndReason) {
|
||||
let Some(session) = self.listen_session.take() else {
|
||||
return;
|
||||
};
|
||||
let listened_ms = if ended_reason == music_dht::device_sync::ListenEndReason::Finished {
|
||||
seconds_to_milliseconds(session.track.duration_seconds)
|
||||
} else {
|
||||
seconds_to_milliseconds(self.state.playback.position_seconds)
|
||||
};
|
||||
if let Err(error) = self.devices.record_listen(
|
||||
session.id,
|
||||
&session.track,
|
||||
session.started_at_ms,
|
||||
listened_ms,
|
||||
ended_reason,
|
||||
) {
|
||||
self.state.settings_error = Some(format!("listening history: {error:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn shuffle_new_context(&mut self) {
|
||||
if self.state.playback.shuffle {
|
||||
self.state.queue.shuffle_upcoming();
|
||||
@@ -1514,6 +1815,8 @@ impl Actor {
|
||||
|
||||
fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) {
|
||||
let Some(client) = self.federation.clone() else {
|
||||
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
|
||||
self.state.playback.status = PlaybackStatus::Stopped;
|
||||
self.state.playback_error = Some("federation is still starting".into());
|
||||
self.publish();
|
||||
return;
|
||||
@@ -1548,11 +1851,11 @@ impl Actor {
|
||||
let key = track.key.clone();
|
||||
let internal = self.internal.clone();
|
||||
let keep = self.state.settings.save_federated_on_listen;
|
||||
let directory = if keep {
|
||||
expand_tilde(&self.state.settings.library_path)
|
||||
} else {
|
||||
self.federation_media_dir.join("stream-cache")
|
||||
};
|
||||
let directory = federated_audio_directory(
|
||||
&self.state.settings.library_path,
|
||||
keep,
|
||||
&self.federation_media_dir,
|
||||
);
|
||||
let stem = format!("fed-{}", sanitize_filename(&track.title));
|
||||
tokio::spawn(async move {
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
@@ -1673,7 +1976,20 @@ impl Actor {
|
||||
}
|
||||
|
||||
fn track(&self, key: &TrackKey) -> Option<&Track> {
|
||||
find_catalog_track(&self.library, &self.state.search.results, key)
|
||||
self.state
|
||||
.queue
|
||||
.items()
|
||||
.iter()
|
||||
.map(|item| &item.track)
|
||||
.find(|track| track.key.matches(key))
|
||||
.or_else(|| find_catalog_track(&self.library, &self.state.search.results, key))
|
||||
.or_else(|| {
|
||||
self.state
|
||||
.similarity_search
|
||||
.results
|
||||
.iter()
|
||||
.find(|track| track.key.matches(key))
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_tracks(&self, keys: &[TrackKey]) -> Vec<Track> {
|
||||
@@ -1688,6 +2004,7 @@ impl Actor {
|
||||
self.library = library.clone();
|
||||
self.state.library = RemoteData::Ready(library);
|
||||
self.reconcile_likes();
|
||||
self.resolve_history_tracks();
|
||||
}
|
||||
Err(error) => {
|
||||
self.state.settings_error = Some(format!("library: {error:#}"));
|
||||
@@ -1696,6 +2013,35 @@ impl Actor {
|
||||
self.publish();
|
||||
}
|
||||
|
||||
fn resolve_history_tracks(&mut self) {
|
||||
let Some(client) = self.federation.clone() else {
|
||||
return;
|
||||
};
|
||||
let unresolved = self
|
||||
.library
|
||||
.recently_played
|
||||
.iter()
|
||||
.filter(|track| track.key.local_id().is_none() && track.cover_uri.is_none())
|
||||
.filter_map(|track| track.key.content_id().map(|id| id.as_str().to_owned()))
|
||||
.collect::<HashSet<_>>();
|
||||
for content_id in unresolved {
|
||||
if !self.pending_history_resolutions.insert(content_id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let internal = self.internal.clone();
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = client
|
||||
.track_by_content_id(&content_id)
|
||||
.await
|
||||
.map_err(|error| format!("history track lookup failed: {error:#}"));
|
||||
let _ = internal
|
||||
.send(InternalEvent::HistoryTrackResolved { content_id, result })
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_likes(&mut self) {
|
||||
let liked = self
|
||||
.catalog
|
||||
@@ -1714,11 +2060,15 @@ impl Actor {
|
||||
) {
|
||||
track.liked = track_is_liked(track, &liked);
|
||||
}
|
||||
for track in &mut self.state.similarity_search.results {
|
||||
track.liked = track_is_liked(track, &liked);
|
||||
}
|
||||
let replacements = self
|
||||
.library
|
||||
.featured_releases
|
||||
.iter()
|
||||
.flat_map(|release| release.tracks.iter())
|
||||
.chain(self.library.recently_played.iter())
|
||||
.chain(
|
||||
self.library
|
||||
.playlists
|
||||
@@ -1848,5 +2198,52 @@ impl Actor {
|
||||
}
|
||||
}
|
||||
|
||||
fn rank_similarity_candidates(
|
||||
mut candidates: Vec<SimilarityCandidate>,
|
||||
max_tracks_per_artist: usize,
|
||||
) -> Vec<Track> {
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
candidates.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut content = HashSet::new();
|
||||
let mut signatures = Vec::new();
|
||||
let mut artist_counts = HashMap::<String, usize>::new();
|
||||
let mut tracks = Vec::new();
|
||||
for (track, _, signature) in candidates {
|
||||
let identity = track
|
||||
.key
|
||||
.content_id()
|
||||
.map_or_else(|| format!("{:?}", track.key), |id| id.as_str().to_owned());
|
||||
if !content.insert(identity) {
|
||||
continue;
|
||||
}
|
||||
if signature.is_some_and(|candidate| {
|
||||
signatures.iter().any(|existing| {
|
||||
music_dht::similarity::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = track.artists.first().map_or_else(
|
||||
|| music_dht::normalize_name(&track.artist),
|
||||
|artist| music_dht::normalize_name(&artist.name),
|
||||
);
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= max_tracks_per_artist.clamp(1, SIMILARITY_RESULT_LIMIT) {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = signature {
|
||||
signatures.push(signature);
|
||||
}
|
||||
tracks.push(track);
|
||||
if tracks.len() >= SIMILARITY_RESULT_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tracks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -4,6 +4,9 @@ use std::path::Path;
|
||||
use furumi_backend_api::SettingsSnapshot;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
const LEGACY_DEFAULT_LIBRARY_PATH: &str = "~/Music/Furumi";
|
||||
const PLATFORM_LIBRARY_PATH_MIGRATION: i64 = 4;
|
||||
|
||||
const MIGRATIONS: &[(i64, &str)] = &[
|
||||
(
|
||||
1,
|
||||
@@ -28,6 +31,19 @@ const MIGRATIONS: &[(i64, &str)] = &[
|
||||
3,
|
||||
"ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';",
|
||||
),
|
||||
(
|
||||
5,
|
||||
r"
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_enabled INTEGER NOT NULL DEFAULT 0 CHECK (similarity_enabled IN (0, 1));
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_model TEXT NOT NULL DEFAULT 'discogs-effnet-bsdynamic-1';
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_profile TEXT NOT NULL DEFAULT 'furumi-full-track-v1';
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_workers INTEGER NOT NULL DEFAULT 2;
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_minimum_score REAL NOT NULL DEFAULT 0.70;
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_max_tracks_per_artist INTEGER NOT NULL DEFAULT 5;
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_federation_consent INTEGER NOT NULL DEFAULT 0 CHECK (similarity_federation_consent IN (0, 1));
|
||||
ALTER TABLE app_settings ADD COLUMN similarity_active_profile TEXT;
|
||||
",
|
||||
),
|
||||
];
|
||||
|
||||
pub struct SettingsStore {
|
||||
@@ -35,32 +51,47 @@ pub struct SettingsStore {
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
pub fn open(path: &Path) -> rusqlite::Result<Self> {
|
||||
pub fn open(path: &Path, default_library_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()?;
|
||||
store.migrate(default_library_path)?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn in_memory() -> rusqlite::Result<Self> {
|
||||
fn in_memory(default_library_path: &Path) -> rusqlite::Result<Self> {
|
||||
let connection = Connection::open_in_memory()?;
|
||||
let mut store = Self { connection };
|
||||
store.migrate()?;
|
||||
store.migrate(default_library_path)?;
|
||||
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
|
||||
save_federated_on_listen, device_name,
|
||||
similarity_enabled, similarity_model, similarity_profile,
|
||||
similarity_workers, similarity_minimum_score,
|
||||
similarity_max_tracks_per_artist, similarity_federation_consent,
|
||||
similarity_active_profile
|
||||
FROM app_settings WHERE singleton_id = 1",
|
||||
[],
|
||||
|row| {
|
||||
let similarity = furumi_backend_api::SimilaritySettingsSnapshot {
|
||||
enabled: row.get::<_, i64>(6)? != 0,
|
||||
model: row.get(7)?,
|
||||
profile: row.get(8)?,
|
||||
workers: usize::try_from(row.get::<_, i64>(9)?).unwrap_or(1),
|
||||
minimum_score: row.get(10)?,
|
||||
max_tracks_per_artist: usize::try_from(row.get::<_, i64>(11)?).unwrap_or(1),
|
||||
federation_consent: row.get::<_, i64>(12)? != 0,
|
||||
active_profile: row.get(13)?,
|
||||
}
|
||||
.normalized();
|
||||
Ok(SettingsSnapshot {
|
||||
network_id: row.get(0)?,
|
||||
library_path: row.get(1)?,
|
||||
@@ -68,6 +99,7 @@ impl SettingsStore {
|
||||
language: row.get(3)?,
|
||||
save_federated_on_listen: row.get::<_, i64>(4)? != 0,
|
||||
device_name: row.get(5)?,
|
||||
similarity,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -81,7 +113,15 @@ impl SettingsStore {
|
||||
federation_enabled = ?3,
|
||||
language = ?4,
|
||||
save_federated_on_listen = ?5,
|
||||
device_name = ?6
|
||||
device_name = ?6,
|
||||
similarity_enabled = ?7,
|
||||
similarity_model = ?8,
|
||||
similarity_profile = ?9,
|
||||
similarity_workers = ?10,
|
||||
similarity_minimum_score = ?11,
|
||||
similarity_max_tracks_per_artist = ?12,
|
||||
similarity_federation_consent = ?13,
|
||||
similarity_active_profile = ?14
|
||||
WHERE singleton_id = 1",
|
||||
params![
|
||||
settings.network_id,
|
||||
@@ -90,12 +130,20 @@ impl SettingsStore {
|
||||
settings.language,
|
||||
i64::from(settings.save_federated_on_listen),
|
||||
settings.device_name,
|
||||
i64::from(settings.similarity.enabled),
|
||||
settings.similarity.model,
|
||||
settings.similarity.profile,
|
||||
i64::try_from(settings.similarity.workers).unwrap_or(16),
|
||||
settings.similarity.minimum_score,
|
||||
i64::try_from(settings.similarity.max_tracks_per_artist).unwrap_or(50),
|
||||
i64::from(settings.similarity.federation_consent),
|
||||
settings.similarity.active_profile,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate(&mut self) -> rusqlite::Result<()> {
|
||||
fn migrate(&mut self, default_library_path: &Path) -> rusqlite::Result<()> {
|
||||
self.connection.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
@@ -119,14 +167,55 @@ impl SettingsStore {
|
||||
}
|
||||
let transaction = self.connection.transaction()?;
|
||||
transaction.execute_batch(sql)?;
|
||||
if version == 5 {
|
||||
transaction.execute(
|
||||
"UPDATE app_settings SET similarity_workers = ?1 WHERE singleton_id = 1",
|
||||
[i64::try_from(
|
||||
furumi_backend_api::SimilaritySettingsSnapshot::default().workers,
|
||||
)
|
||||
.unwrap_or(1)],
|
||||
)?;
|
||||
}
|
||||
transaction.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (?1)",
|
||||
[version],
|
||||
)?;
|
||||
transaction.commit()?;
|
||||
}
|
||||
self.migrate_platform_library_path(default_library_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_platform_library_path(
|
||||
&mut self,
|
||||
default_library_path: &Path,
|
||||
) -> rusqlite::Result<()> {
|
||||
let applied = self
|
||||
.connection
|
||||
.query_row(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = ?1",
|
||||
[PLATFORM_LIBRARY_PATH_MIGRATION],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.optional()?
|
||||
.is_some();
|
||||
if applied {
|
||||
return Ok(());
|
||||
}
|
||||
let default_library_path = default_library_path.to_string_lossy();
|
||||
let transaction = self.connection.transaction()?;
|
||||
transaction.execute(
|
||||
"UPDATE app_settings
|
||||
SET library_path = ?1
|
||||
WHERE library_path = ?2 OR trim(library_path) = ''",
|
||||
params![default_library_path.as_ref(), LEGACY_DEFAULT_LIBRARY_PATH],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO schema_migrations (version) VALUES (?1)",
|
||||
[PLATFORM_LIBRARY_PATH_MIGRATION],
|
||||
)?;
|
||||
transaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -135,19 +224,58 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn migration_creates_defaults_and_settings_round_trip() {
|
||||
let store = SettingsStore::in_memory().unwrap();
|
||||
let default_library_path = Path::new("/platform/furumi/federation-media");
|
||||
let store = SettingsStore::in_memory(default_library_path).unwrap();
|
||||
let mut settings = store.load().unwrap();
|
||||
assert_eq!(settings.network_id, "furumi");
|
||||
assert_eq!(
|
||||
settings.library_path,
|
||||
default_library_path.to_string_lossy()
|
||||
);
|
||||
assert!(settings.federation_enabled);
|
||||
assert!(settings.save_federated_on_listen);
|
||||
assert!(settings.device_name.is_empty());
|
||||
assert!(!settings.similarity.enabled);
|
||||
assert_eq!(
|
||||
settings.similarity.workers,
|
||||
furumi_backend_api::SimilaritySettingsSnapshot::default().workers
|
||||
);
|
||||
assert!((settings.similarity.minimum_score - 0.70).abs() < f32::EPSILON);
|
||||
|
||||
settings.network_id = "friends".into();
|
||||
settings.device_name = "Studio Mac".into();
|
||||
settings.library_path = "/music/library".into();
|
||||
settings.federation_enabled = false;
|
||||
settings.similarity.enabled = true;
|
||||
settings.similarity.workers = 7;
|
||||
settings.similarity.minimum_score = 0.82;
|
||||
settings.similarity.max_tracks_per_artist = 9;
|
||||
settings.similarity.federation_consent = true;
|
||||
settings.similarity.active_profile = Some("sim1:test".into());
|
||||
store.save(&settings).unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_default_migration_preserves_a_custom_library_path() {
|
||||
let first_default = Path::new("/first/furumi/federation-media");
|
||||
let mut store = SettingsStore::in_memory(first_default).unwrap();
|
||||
let mut settings = store.load().unwrap();
|
||||
settings.library_path = "/custom/music".into();
|
||||
store.save(&settings).unwrap();
|
||||
store
|
||||
.connection
|
||||
.execute(
|
||||
"DELETE FROM schema_migrations WHERE version = ?1",
|
||||
[PLATFORM_LIBRARY_PATH_MIGRATION],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.migrate(Path::new("/second/furumi/federation-media"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.load().unwrap().library_path, "/custom/music");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,18 @@ pub(super) fn expand_tilde(value: &str) -> std::path::PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn federated_audio_directory(
|
||||
library_path: &str,
|
||||
keep: bool,
|
||||
federation_cache_dir: &std::path::Path,
|
||||
) -> std::path::PathBuf {
|
||||
if keep {
|
||||
expand_tilde(library_path)
|
||||
} else {
|
||||
federation_cache_dir.join("stream-cache")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_device_name(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
@@ -31,10 +43,18 @@ pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) ->
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(super) fn seconds_to_milliseconds(seconds: f64) -> i64 {
|
||||
if !seconds.is_finite() || seconds <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let duration = Duration::from_secs_f64(seconds.min(Duration::MAX.as_secs_f64()));
|
||||
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
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,
|
||||
RENDEZVOUS_ID, SIMILARITY_DHT_ID, SIMILARITY_ID, TICKET_ID,
|
||||
};
|
||||
|
||||
let manifest = CapabilityManifest::frid("furumi-desktop", env!("CARGO_PKG_VERSION"));
|
||||
@@ -70,6 +90,8 @@ pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
|
||||
protocol("Rendezvous", RENDEZVOUS_ID),
|
||||
protocol("Music DHT", MUSIC_DHT_ID),
|
||||
protocol("Catalog", CATALOG_ID),
|
||||
protocol("Similarity search", SIMILARITY_ID),
|
||||
protocol("Similarity routing", SIMILARITY_DHT_ID),
|
||||
VersionEntrySnapshot {
|
||||
name: "Audio transfer".into(),
|
||||
version: federation::AUDIO_PROTOCOL_VERSION.to_string(),
|
||||
@@ -98,6 +120,7 @@ pub(super) fn find_catalog_track<'a>(
|
||||
.featured_releases
|
||||
.iter()
|
||||
.flat_map(|release| release.tracks.iter())
|
||||
.chain(library.recently_played.iter())
|
||||
.chain(
|
||||
library
|
||||
.playlists
|
||||
@@ -785,10 +808,22 @@ pub(super) fn library_snapshot(
|
||||
tracks,
|
||||
});
|
||||
}
|
||||
let recently_played = releases
|
||||
.iter()
|
||||
.flat_map(|release| release.tracks.iter().cloned())
|
||||
.take(12)
|
||||
let recently_played = catalog
|
||||
.listen_history(500)?
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let content_id = ContentId::parse(entry.content_id.clone()).ok()?;
|
||||
let mut track = catalog
|
||||
.track_by_content_id(content_id.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.map_or_else(
|
||||
|| history_placeholder(&entry, content_id),
|
||||
|track| library_track(track, ""),
|
||||
);
|
||||
track.liked = track_is_liked(&track, &liked_ids);
|
||||
Some(track)
|
||||
})
|
||||
.collect();
|
||||
let mut playlists = Vec::new();
|
||||
for card in catalog.playlists()? {
|
||||
@@ -816,6 +851,43 @@ pub(super) fn library_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn history_placeholder(entry: &furumi_library::ListenHistoryEntry, content_id: ContentId) -> Track {
|
||||
let peer_id = "history".to_owned();
|
||||
let artist = ArtistRef {
|
||||
key: ArtistKey::Federation {
|
||||
peer_id: peer_id.clone(),
|
||||
id: format!("name:{}", music_dht::normalize_name(&entry.artist)),
|
||||
},
|
||||
name: entry.artist.clone(),
|
||||
};
|
||||
Track {
|
||||
key: TrackKey::remote(content_id.clone()),
|
||||
title: entry.title.clone(),
|
||||
artist: entry.artist.clone(),
|
||||
artists: vec![artist],
|
||||
featured_artists: Vec::new(),
|
||||
release: String::new(),
|
||||
release_id: ReleaseKey::Federation {
|
||||
peer_id: peer_id.clone(),
|
||||
id: format!("history:{}", entry.listen_id),
|
||||
},
|
||||
duration_seconds: 0.0,
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
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,
|
||||
content_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet<String>) -> bool {
|
||||
track
|
||||
.key
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_library_path_controls_permanent_federated_audio_storage() {
|
||||
let cache = std::path::Path::new("/cache/furumi-desktop/federation-media");
|
||||
|
||||
assert_eq!(
|
||||
federated_audio_directory("/chosen/music", true, cache),
|
||||
std::path::Path::new("/chosen/music")
|
||||
);
|
||||
assert_eq!(
|
||||
federated_audio_directory("/chosen/music", false, cache),
|
||||
cache.join("stream-cache")
|
||||
);
|
||||
}
|
||||
|
||||
fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
|
||||
Track {
|
||||
key,
|
||||
@@ -26,6 +40,57 @@ fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
|
||||
}
|
||||
}
|
||||
|
||||
fn similarity_test_candidate(
|
||||
id: i64,
|
||||
artist: &str,
|
||||
score: f32,
|
||||
signature: u8,
|
||||
) -> SimilarityCandidate {
|
||||
let mut track = merge_test_track(
|
||||
TrackKey::local(LocalTrackId::new(id)),
|
||||
AudioSource::LocalFile(format!("{id}.flac").into()),
|
||||
);
|
||||
track.title = format!("Track {id}");
|
||||
track.artist = artist.into();
|
||||
track.artists[0].name = artist.into();
|
||||
(
|
||||
track,
|
||||
score,
|
||||
Some([signature; music_dht::similarity::SIMILARITY_SIGNATURE_BYTES]),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_results_apply_one_artist_cap_after_combining_sources() {
|
||||
let tracks = rank_similarity_candidates(
|
||||
vec![
|
||||
similarity_test_candidate(1, "same artist", 0.70, 1),
|
||||
similarity_test_candidate(2, "other artist", 0.90, 2),
|
||||
similarity_test_candidate(3, "same artist", 0.80, 3),
|
||||
similarity_test_candidate(4, "same artist", 0.60, 4),
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(tracks.len(), 2);
|
||||
assert_eq!(tracks[0].title, "Track 2");
|
||||
assert_eq!(tracks[1].title, "Track 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similarity_results_drop_cross_source_near_duplicates() {
|
||||
let tracks = rank_similarity_candidates(
|
||||
vec![
|
||||
similarity_test_candidate(1, "first", 0.90, 7),
|
||||
similarity_test_candidate(2, "second", 0.80, 7),
|
||||
],
|
||||
5,
|
||||
);
|
||||
|
||||
assert_eq!(tracks.len(), 1);
|
||||
assert_eq!(tracks[0].title, "Track 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_search_results_deduplicates_source_keys() {
|
||||
let artist = Artist {
|
||||
|
||||
@@ -393,6 +393,44 @@ impl Queue {
|
||||
self.play_next_end = Some(insertion + count);
|
||||
}
|
||||
|
||||
/// Moves one concrete queue occurrence to a final visual position.
|
||||
pub fn move_item(&mut self, id: QueueItemId, target_index: usize) -> bool {
|
||||
let Some(source_index) = self.items.iter().position(|item| item.id == id) else {
|
||||
return false;
|
||||
};
|
||||
let target_index = target_index.min(self.items.len().saturating_sub(1));
|
||||
if source_index == target_index {
|
||||
return false;
|
||||
}
|
||||
let current_id = self.current().map(|item| item.id);
|
||||
let item = self.items.remove(source_index);
|
||||
self.items.insert(target_index, item);
|
||||
self.current =
|
||||
current_id.and_then(|current| self.items.iter().position(|item| item.id == current));
|
||||
self.play_next_end = None;
|
||||
self.original_order = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes one concrete queue occurrence and keeps the nearest item selected.
|
||||
///
|
||||
/// Returns whether the removed occurrence was the current one.
|
||||
pub fn remove_item(&mut self, id: QueueItemId) -> Option<bool> {
|
||||
let index = self.items.iter().position(|item| item.id == id)?;
|
||||
let removed_current = self.current == Some(index);
|
||||
self.items.remove(index);
|
||||
self.current = match self.current {
|
||||
None => None,
|
||||
Some(_) if self.items.is_empty() => None,
|
||||
Some(current) if index < current => Some(current - 1),
|
||||
Some(current) if index == current => Some(index.min(self.items.len() - 1)),
|
||||
Some(current) => Some(current),
|
||||
};
|
||||
self.play_next_end = None;
|
||||
self.original_order = None;
|
||||
Some(removed_current)
|
||||
}
|
||||
|
||||
pub fn replace_matching_track(&mut self, replacement: &Track) {
|
||||
for item in &mut self.items {
|
||||
if item.track.key.matches(&replacement.key) {
|
||||
@@ -589,6 +627,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_queue_items_preserves_the_current_occurrence() {
|
||||
let mut queue = Queue::default();
|
||||
queue.replace_context((1..=4).map(track).collect(), 1);
|
||||
let moved = queue.items()[0].id;
|
||||
let current = queue.current().unwrap().id;
|
||||
|
||||
assert!(queue.move_item(moved, 3));
|
||||
|
||||
assert_eq!(queue.current().unwrap().id, current);
|
||||
assert_eq!(queue.current_index(), Some(0));
|
||||
assert_eq!(
|
||||
queue
|
||||
.items()
|
||||
.iter()
|
||||
.map(|item| item.track.key.local_id().unwrap().get())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 3, 4, 1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_current_queue_item_selects_the_next_occurrence() {
|
||||
let mut queue = Queue::default();
|
||||
queue.replace_context((1..=3).map(track).collect(), 1);
|
||||
let current = queue.current().unwrap().id;
|
||||
|
||||
assert_eq!(queue.remove_item(current), Some(true));
|
||||
|
||||
assert_eq!(queue.current_index(), Some(1));
|
||||
assert_eq!(queue.current().unwrap().track.title, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffle_preserves_current_track_and_can_restore_the_tail() {
|
||||
let mut queue = Queue::default();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 3-6.7"/>
|
||||
<path d="M3 4.5v5h5"/>
|
||||
<path d="M12 7.5V12l3 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
+162
-3
@@ -29,7 +29,7 @@ slint::include_modules!();
|
||||
|
||||
mod render;
|
||||
use render::{
|
||||
breadcrumb_screen, contributor_lines, find_artist_key, find_release_key, model,
|
||||
breadcrumb_screen, contributor_lines, find_release_key, model, parse_artist_key,
|
||||
parse_track_key, release_artist_credits, render, render_catalog, render_current_track,
|
||||
render_playback, render_queue, render_search, render_shell, render_track_info,
|
||||
selected_release, track_context,
|
||||
@@ -99,6 +99,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
let screen = match target.as_str() {
|
||||
"search" => Screen::Search,
|
||||
"library" => Screen::Library,
|
||||
"history" => Screen::History,
|
||||
_ => Screen::Home,
|
||||
};
|
||||
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
|
||||
@@ -144,6 +145,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
"add-end" => UiAction::AddToEnd(vec![key]),
|
||||
"information" => UiAction::ShowTrackInfo(key),
|
||||
"playlist" => UiAction::ShowPlaylistPicker(key),
|
||||
"similar" => UiAction::SearchSimilar(key),
|
||||
// The remaining presentation actions terminate here until
|
||||
// their backend capabilities are introduced.
|
||||
_ => return,
|
||||
@@ -152,6 +154,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
|
||||
}
|
||||
});
|
||||
bind_playlist_callbacks(window, state, backend);
|
||||
bind_queue_callbacks(window, state, backend);
|
||||
bind_device_callbacks(window, state, backend);
|
||||
bind_settings_callbacks(window, state, backend);
|
||||
window.on_dismiss_error({
|
||||
@@ -236,6 +239,9 @@ fn bind_playlist_callbacks(
|
||||
let backend = backend.clone();
|
||||
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker)
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_queue_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
|
||||
window.on_play_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
@@ -251,6 +257,41 @@ fn bind_playlist_callbacks(
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_move_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |id, target_index| {
|
||||
let (Ok(id), Ok(target_index)) = (id.parse::<u64>(), usize::try_from(target_index))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::MoveQueueItem {
|
||||
item_id: QueueItemId::new(id),
|
||||
target_index,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_remove_queue_item({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |id| {
|
||||
if let Ok(id) = id.parse::<u64>() {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::RemoveQueueItem(QueueItemId::new(id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_catalog_callbacks(
|
||||
@@ -295,7 +336,7 @@ fn bind_catalog_callbacks(
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |target| {
|
||||
let screen = with_state(&state, |state| breadcrumb_screen(state, &target));
|
||||
let screen = breadcrumb_screen(&target);
|
||||
if let Some(screen) = screen {
|
||||
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
|
||||
}
|
||||
@@ -306,7 +347,7 @@ fn bind_catalog_callbacks(
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |key| {
|
||||
let artist = with_state(&state, |state| find_artist_key(state, &key));
|
||||
let artist = parse_artist_key(&key);
|
||||
if let Some(key) = artist {
|
||||
dispatch_action(
|
||||
&window,
|
||||
@@ -519,6 +560,7 @@ fn bind_settings_callbacks(
|
||||
);
|
||||
}
|
||||
});
|
||||
bind_similarity_settings_callbacks(window, state, backend);
|
||||
window.on_language_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
@@ -534,6 +576,118 @@ fn bind_settings_callbacks(
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "one explicit binding per similarity setting keeps callback ownership obvious"
|
||||
)]
|
||||
fn bind_similarity_settings_callbacks(
|
||||
window: &AppWindow,
|
||||
state: &Arc<Mutex<AppState>>,
|
||||
backend: &BackendHandle,
|
||||
) {
|
||||
window.on_similarity_enabled_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |enabled| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityEnabledChanged(enabled),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_model_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |model| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityModelChanged(model.to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_profile_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |profile| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityProfileChanged(profile.to_string()),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_workers_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |workers| {
|
||||
if let Ok(workers) = usize::try_from(workers) {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityWorkersChanged(workers),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_similarity_minimum_score_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |minimum_score| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityMinimumScoreChanged(minimum_score),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_similarity_max_tracks_per_artist_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |maximum| {
|
||||
if let Ok(maximum) = usize::try_from(maximum) {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityMaxTracksPerArtistChanged(maximum),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
window.on_similarity_federation_consent_changed({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move |consent| {
|
||||
dispatch_action(
|
||||
&window,
|
||||
&state,
|
||||
&backend,
|
||||
UiAction::SimilarityFederationConsentChanged(consent),
|
||||
);
|
||||
}
|
||||
});
|
||||
window.on_clear_similarity({
|
||||
let window = window.as_weak();
|
||||
let state = Arc::clone(state);
|
||||
let backend = backend.clone();
|
||||
move || dispatch_action(&window, &state, &backend, UiAction::ClearSimilarity)
|
||||
});
|
||||
}
|
||||
|
||||
fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
|
||||
window.on_choose_library_path({
|
||||
let window = window.as_weak();
|
||||
@@ -717,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|
||||
|| previous.federation_debug != state.backend.federation_debug
|
||||
|| previous.connected_devices != state.backend.connected_devices
|
||||
|| previous.settings != state.backend.settings
|
||||
|| previous.similarity_status != state.backend.similarity_status
|
||||
|| previous.playback_error != state.backend.playback_error
|
||||
|| previous.settings_error != state.backend.settings_error;
|
||||
let catalog_changed = previous.library != state.backend.library
|
||||
|| previous.search != state.backend.search
|
||||
|| previous.queue != state.backend.queue;
|
||||
let similarity_changed = previous.similarity_search != state.backend.similarity_search;
|
||||
let queue_changed = previous.queue != state.backend.queue;
|
||||
if shell_changed {
|
||||
render_shell(window, state);
|
||||
@@ -735,6 +891,9 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|
||||
render_queue(window, state);
|
||||
render_current_track(window, state);
|
||||
}
|
||||
if similarity_changed {
|
||||
render::render_similarity(window, state);
|
||||
}
|
||||
render_playback(window, state);
|
||||
});
|
||||
}
|
||||
|
||||
+211
-27
@@ -3,6 +3,7 @@ pub(super) fn render(window: &AppWindow, state: &AppState) {
|
||||
render_shell(window, state);
|
||||
render_catalog(window, state);
|
||||
render_search(window, state);
|
||||
render_similarity(window, state);
|
||||
render_queue(window, state);
|
||||
render_current_track(window, state);
|
||||
render_playback(window, state);
|
||||
@@ -15,7 +16,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
window.set_search_label(strings.search.into());
|
||||
window.set_library_label(strings.library.into());
|
||||
window.set_queue_label(strings.queue.into());
|
||||
window.set_recent_label(strings.recently_played.into());
|
||||
window.set_history_label(strings.listening_history.into());
|
||||
window.set_featured_label(strings.made_for_listening.into());
|
||||
window.set_search_placeholder(strings.search_placeholder.into());
|
||||
window.set_active_screen(
|
||||
@@ -23,6 +24,8 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
Screen::Home => "home",
|
||||
Screen::Search => "search",
|
||||
Screen::Library => "library",
|
||||
Screen::History => "history",
|
||||
Screen::Similarity => "similarity",
|
||||
Screen::Artist(_) => "artist",
|
||||
Screen::Release(_, _) => "release",
|
||||
Screen::Playlist(_) => "playlist",
|
||||
@@ -40,6 +43,46 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
window.set_library_path(state.backend.settings.library_path.clone().into());
|
||||
window.set_federation_enabled(state.backend.settings.federation_enabled);
|
||||
window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen);
|
||||
let similarity = &state.backend.settings.similarity;
|
||||
window.set_similarity_enabled(similarity.enabled);
|
||||
window.set_similarity_model(similarity.model.clone().into());
|
||||
window.set_similarity_models(model(vec![SharedString::from(
|
||||
"discogs-effnet-bsdynamic-1",
|
||||
)]));
|
||||
window.set_similarity_profile(similarity.profile.clone().into());
|
||||
window.set_similarity_profiles(model(vec![SharedString::from("furumi-full-track-v1")]));
|
||||
window.set_similarity_workers(i32::try_from(similarity.workers).unwrap_or(16));
|
||||
window.set_similarity_minimum_score(similarity.minimum_score);
|
||||
window.set_similarity_max_tracks_per_artist(
|
||||
i32::try_from(similarity.max_tracks_per_artist).unwrap_or(50),
|
||||
);
|
||||
window.set_similarity_federation_consent(similarity.federation_consent);
|
||||
let status = &state.backend.similarity_status;
|
||||
window.set_similarity_status_phase(status.phase.clone().into());
|
||||
window.set_similarity_status_progress(
|
||||
format!("{} / {}", status.completed_tracks, status.total_tracks).into(),
|
||||
);
|
||||
window.set_similarity_status_storage(
|
||||
format!(
|
||||
"{} / {}",
|
||||
status.stored_vectors,
|
||||
compact_bytes(status.stored_bytes)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
window.set_similarity_status_current(
|
||||
status
|
||||
.current_track
|
||||
.clone()
|
||||
.or_else(|| status.error.clone())
|
||||
.unwrap_or_else(|| {
|
||||
status.active_profile.as_deref().map_or_else(
|
||||
|| "Index is not ready".into(),
|
||||
|profile| format!("Active: {profile}"),
|
||||
)
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
window.set_selected_language(state.backend.settings.language.clone().into());
|
||||
window.set_available_languages(model(vec![SharedString::from("English")]));
|
||||
window.set_search_query(state.frontend.search_query.clone().into());
|
||||
@@ -47,6 +90,7 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
render_device_shell(window, state);
|
||||
let (network_busy, network_text) = federation_status(state);
|
||||
window.set_federation_status_busy(network_busy);
|
||||
window.set_federation_status_running(state.backend.federation_debug.running);
|
||||
window.set_federation_status_text(network_text.into());
|
||||
render_federation_debug(window, state);
|
||||
render_build_info(window, state);
|
||||
@@ -62,6 +106,18 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
|
||||
render_track_info(window, state);
|
||||
}
|
||||
|
||||
fn compact_bytes(bytes: u64) -> String {
|
||||
const KIB: u64 = 1024;
|
||||
const MIB: u64 = 1024 * KIB;
|
||||
if bytes >= MIB {
|
||||
format!("{}.{:01} MiB", bytes / MIB, bytes % MIB * 10 / MIB)
|
||||
} else if bytes >= KIB {
|
||||
format!("{}.{:01} KiB", bytes / KIB, bytes % KIB * 10 / KIB)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
fn render_federation_debug(window: &AppWindow, state: &AppState) {
|
||||
let debug = &state.backend.federation_debug;
|
||||
window.set_federation_debug_node(
|
||||
@@ -184,13 +240,27 @@ fn render_device_shell(window: &AppWindow, state: &AppState) {
|
||||
devices
|
||||
.pending_pairings
|
||||
.iter()
|
||||
.map(|pending| PairingView {
|
||||
request_id: pending.request_id.clone().into(),
|
||||
name: pending.name.clone().into(),
|
||||
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
|
||||
.into(),
|
||||
group_conflict: pending.requester_group_id.is_some()
|
||||
&& pending.requester_group_active_devices > 1,
|
||||
.map(|pending| {
|
||||
let group_conflict = pending.requester_group_id.is_some()
|
||||
&& pending.requester_group_active_devices > 1;
|
||||
PairingView {
|
||||
request_id: pending.request_id.clone().into(),
|
||||
name: pending.name.clone().into(),
|
||||
details: format!("Furumi {} · {}", pending.client_version, pending.device_id)
|
||||
.into(),
|
||||
group_conflict,
|
||||
group_summary: pending
|
||||
.requester_group_id
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |group_id| {
|
||||
format!(
|
||||
"Their group · {} active devices · {}",
|
||||
pending.requester_group_active_devices,
|
||||
group_id.chars().take(24).collect::<String>()
|
||||
)
|
||||
})
|
||||
.into(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
));
|
||||
@@ -403,12 +473,18 @@ fn find_track<'a>(state: &'a AppState, key: &TrackKey) -> Option<&'a Track> {
|
||||
.flat_map(|library| library.featured_releases.iter())
|
||||
.flat_map(|release| release.tracks.iter()),
|
||||
)
|
||||
.chain(
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.recently_played.iter()),
|
||||
)
|
||||
.chain(
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.playlists.iter())
|
||||
.flat_map(|playlist| playlist.tracks.iter()),
|
||||
)
|
||||
.chain(state.backend.similarity_search.results.iter())
|
||||
.find(|track| track.key.matches(key))
|
||||
}
|
||||
|
||||
@@ -462,11 +538,12 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
|
||||
let tracks: Vec<Track> = match context {
|
||||
"release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks),
|
||||
"search" => state.backend.search.results.tracks.clone(),
|
||||
"recent" => ready_library(state)
|
||||
"history" => ready_library(state)
|
||||
.into_iter()
|
||||
.flat_map(|library| library.recently_played.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
"similarity" => state.backend.similarity_search.results.clone(),
|
||||
"artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| {
|
||||
ready_library(state)
|
||||
.into_iter()
|
||||
@@ -492,17 +569,18 @@ pub(super) fn track_context(state: &AppState, context: &str) -> Vec<TrackKey> {
|
||||
tracks.into_iter().map(|track| track.key).collect()
|
||||
}
|
||||
|
||||
pub(super) fn breadcrumb_screen(state: &AppState, target: &SharedString) -> Option<Screen> {
|
||||
pub(super) fn breadcrumb_screen(target: &SharedString) -> Option<Screen> {
|
||||
match target.as_str() {
|
||||
"home" => Some(Screen::Home),
|
||||
"search" => Some(Screen::Search),
|
||||
"library" => Some(Screen::Library),
|
||||
"history" => Some(Screen::History),
|
||||
value if value.starts_with("playlist:") => value
|
||||
.trim_start_matches("playlist:")
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.map(Screen::Playlist),
|
||||
_ => find_artist_key(state, target).map(Screen::Artist),
|
||||
_ => parse_artist_key(target).map(Screen::Artist),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +604,14 @@ fn breadcrumbs(state: &AppState) -> Vec<BreadcrumbView> {
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Library".into(), String::new(), false),
|
||||
],
|
||||
Screen::History => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Listening history".into(), String::new(), false),
|
||||
],
|
||||
Screen::Similarity => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb("Similar tracks".into(), String::new(), false),
|
||||
],
|
||||
Screen::Artist(_) => vec![
|
||||
crumb("Home".into(), "home".into(), true),
|
||||
crumb(
|
||||
@@ -588,7 +674,11 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
|
||||
});
|
||||
window.set_releases(model(releases));
|
||||
let artists = library.map_or_else(Vec::new, |library| {
|
||||
library.artists.iter().map(artist_to_view).collect()
|
||||
library
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| artist_to_view(artist, &library.featured_releases))
|
||||
.collect()
|
||||
});
|
||||
window.set_artists(model(artists));
|
||||
|
||||
@@ -660,10 +750,10 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
|
||||
|
||||
render_catalog_detail(window, state, selected_artist);
|
||||
|
||||
let recent = library.map_or_else(Vec::new, |library| {
|
||||
let history = library.map_or_else(Vec::new, |library| {
|
||||
tracks_to_views(&library.recently_played, state)
|
||||
});
|
||||
window.set_tracks(model(recent));
|
||||
window.set_history_tracks(model(history));
|
||||
let playlist = selected_playlist(state);
|
||||
window.set_playlist_title(
|
||||
playlist
|
||||
@@ -741,6 +831,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
|
||||
let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref());
|
||||
QueueView {
|
||||
key: item.id.get().to_string().into(),
|
||||
track_key: format_track_key(&item.track.key).into(),
|
||||
title: item.track.title.clone().into(),
|
||||
artist: item.track.artist.clone().into(),
|
||||
artist_key: item
|
||||
@@ -753,6 +844,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
|
||||
release: item.track.release.clone().into(),
|
||||
release_key: format_release_key(&item.track.release_id).into(),
|
||||
active: state.backend.queue.current_index() == Some(index),
|
||||
liked: item.track.liked,
|
||||
artwork,
|
||||
has_artwork,
|
||||
}
|
||||
@@ -809,7 +901,12 @@ pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
|
||||
pub(super) fn render_search(window: &AppWindow, state: &AppState) {
|
||||
let search = &state.backend.search;
|
||||
window.set_search_artists(model(
|
||||
search.results.artists.iter().map(artist_to_view).collect(),
|
||||
search
|
||||
.results
|
||||
.artists
|
||||
.iter()
|
||||
.map(|artist| artist_to_view(artist, &search.results.releases))
|
||||
.collect(),
|
||||
));
|
||||
window.set_search_releases(model(
|
||||
search
|
||||
@@ -822,6 +919,14 @@ pub(super) fn render_search(window: &AppWindow, state: &AppState) {
|
||||
window.set_search_results(model(tracks_to_views(&search.results.tracks, state)));
|
||||
}
|
||||
|
||||
pub(super) fn render_similarity(window: &AppWindow, state: &AppState) {
|
||||
let search = &state.backend.similarity_search;
|
||||
window.set_similarity_source_title(search.source_title.clone().into());
|
||||
window.set_similarity_pending(search.pending);
|
||||
window.set_similarity_error(search.error.clone().unwrap_or_default().into());
|
||||
window.set_similarity_tracks(model(tracks_to_views(&search.results, state)));
|
||||
}
|
||||
|
||||
fn search_duration_label(milliseconds: u64) -> String {
|
||||
if milliseconds < 1_000 {
|
||||
format!("{milliseconds} ms")
|
||||
@@ -1028,8 +1133,13 @@ pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec<Artist
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn artist_to_view(artist: &Artist) -> ArtistView {
|
||||
let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref());
|
||||
fn artist_to_view(artist: &Artist, releases: &[Release]) -> ArtistView {
|
||||
let artwork_uri = artist
|
||||
.artwork
|
||||
.uri
|
||||
.as_deref()
|
||||
.or_else(|| fallback_artist_artwork(artist, releases));
|
||||
let (artwork, has_artwork) = load_artwork(artwork_uri);
|
||||
ArtistView {
|
||||
key: format_artist_key(&artist.key).into(),
|
||||
name: artist.name.clone().into(),
|
||||
@@ -1044,6 +1154,30 @@ fn artist_to_view(artist: &Artist) -> ArtistView {
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_artist_artwork<'a>(artist: &Artist, releases: &'a [Release]) -> Option<&'a str> {
|
||||
let belongs_to_artist = |release: &&Release| {
|
||||
release
|
||||
.artists
|
||||
.iter()
|
||||
.any(|candidate| candidate.key == artist.key)
|
||||
&& release.artwork.uri.is_some()
|
||||
};
|
||||
let mut candidates = releases
|
||||
.iter()
|
||||
.filter(|release| release.is_album())
|
||||
.filter(belongs_to_artist)
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
candidates.extend(releases.iter().filter(belongs_to_artist));
|
||||
}
|
||||
let hash = artist.name.bytes().fold(0_usize, |hash, byte| {
|
||||
hash.wrapping_mul(31) ^ usize::from(byte)
|
||||
});
|
||||
candidates
|
||||
.get(hash % candidates.len().max(1))
|
||||
.and_then(|release| release.artwork.uri.as_deref())
|
||||
}
|
||||
|
||||
fn release_to_view(release: &Release) -> ReleaseView {
|
||||
let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref());
|
||||
ReleaseView {
|
||||
@@ -1296,16 +1430,21 @@ fn format_release_key(key: &ReleaseKey) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option<ArtistKey> {
|
||||
let local = match &state.backend.library {
|
||||
RemoteData::Ready(library) => library.artists.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
local
|
||||
.iter()
|
||||
.chain(state.backend.search.results.artists.iter())
|
||||
.find(|artist| format_artist_key(&artist.key) == value.as_str())
|
||||
.map(|artist| artist.key.clone())
|
||||
pub(super) fn parse_artist_key(value: &SharedString) -> Option<ArtistKey> {
|
||||
if let Some(local) = value.strip_prefix("local-artist:") {
|
||||
return local
|
||||
.parse::<i64>()
|
||||
.ok()
|
||||
.map(|id| ArtistKey::local(furumi_domain::ArtistId::new(id)));
|
||||
}
|
||||
let (peer_id, id) = value.strip_prefix("fed-artist:")?.split_once(':')?;
|
||||
if peer_id.is_empty() || id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ArtistKey::Federation {
|
||||
peer_id: peer_id.into(),
|
||||
id: id.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option<ReleaseKey> {
|
||||
@@ -1380,6 +1519,51 @@ mod tests {
|
||||
assert_eq!(decoded.size().height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_link_key_is_resolved_without_a_top_level_artist_row() {
|
||||
let expected = ArtistKey::Federation {
|
||||
peer_id: "peer-a".into(),
|
||||
id: "guest:artist".into(),
|
||||
};
|
||||
let encoded: SharedString = format_artist_key(&expected).into();
|
||||
|
||||
assert_eq!(parse_artist_key(&encoded), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_without_an_image_uses_one_of_its_album_covers() {
|
||||
let key = ArtistKey::local(furumi_domain::ArtistId::new(12));
|
||||
let artist = Artist {
|
||||
key: key.clone(),
|
||||
source: CatalogSource::Local,
|
||||
name: "Artist".into(),
|
||||
artwork: furumi_domain::Artwork::default(),
|
||||
release_count: 1,
|
||||
track_count: 1,
|
||||
};
|
||||
let release = Release {
|
||||
key: ReleaseKey::local(furumi_domain::ReleaseId::new(8)),
|
||||
source: CatalogSource::Local,
|
||||
title: "Album".into(),
|
||||
artists: vec![ArtistRef {
|
||||
key,
|
||||
name: "Artist".into(),
|
||||
}],
|
||||
featured_artists: Vec::new(),
|
||||
release_type: "album".into(),
|
||||
year: None,
|
||||
artwork: furumi_domain::Artwork {
|
||||
uri: Some("/music/album-cover.jpg".into()),
|
||||
},
|
||||
tracks: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
fallback_artist_artwork(&artist, &[release]),
|
||||
Some("/music/album-cover.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_credits_include_artists_found_only_on_tracks() {
|
||||
let pasha = ArtistRef {
|
||||
|
||||
+365
-46
@@ -1,6 +1,37 @@
|
||||
import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint";
|
||||
import { ArtistLinkLineView, ArtistLinkView, ArtistView, BreadcrumbView, DeviceView, PairingView, PlaylistView, QueueView, ReleaseView, TrackView, VersionView } from "models.slint";
|
||||
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackInfoArtists, TrackList } from "components.slint";
|
||||
import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, FederationBadge, IconButton, InfoField, LinkText, MarqueeText, NavButton, PlaylistNavButton, RecommendedButton, SearchArtistGrid, SearchField, SearchReleaseGrid, StaticArtistList, TrackActionButton, TrackInfoArtists, TrackList, TrackMenuItem } from "components.slint";
|
||||
|
||||
component FederationStateIndicator inherits Rectangle {
|
||||
in property <bool> busy;
|
||||
in property <bool> running;
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
horizontal-stretch: 0;
|
||||
background: transparent;
|
||||
|
||||
if !root.busy: Rectangle {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: self.width / 2;
|
||||
background: root.running ? #55dbaa : #697080;
|
||||
}
|
||||
|
||||
if root.busy: pulse := Rectangle {
|
||||
private property <int> phase: floor(1 * mod(animation-tick(), 2s) / 250ms);
|
||||
private property <int> level: self.phase < 4 ? self.phase : 7 - self.phase;
|
||||
width: 12px - self.level * 2px;
|
||||
height: self.width;
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: self.width / 2;
|
||||
border-width: 2px;
|
||||
border-color: #55dbaa;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
title: "Furumi Desktop";
|
||||
@@ -17,7 +48,7 @@ export component AppWindow inherits Window {
|
||||
in property <string> search-label: "Search";
|
||||
in property <string> library-label: "Your library";
|
||||
in property <string> queue-label: "Queue";
|
||||
in property <string> recent-label: "Recently played";
|
||||
in property <string> history-label: "Listening history";
|
||||
in property <string> featured-label: "Made for listening";
|
||||
in property <string> search-placeholder: "Artists, albums or tracks";
|
||||
in-out property <string> search-query;
|
||||
@@ -29,6 +60,19 @@ export component AppWindow inherits Window {
|
||||
in property <string> library-path;
|
||||
in property <bool> federation-enabled;
|
||||
in property <bool> save-federated-on-listen;
|
||||
in property <bool> similarity-enabled;
|
||||
in property <string> similarity-model;
|
||||
in property <[string]> similarity-models;
|
||||
in property <string> similarity-profile;
|
||||
in property <[string]> similarity-profiles;
|
||||
in property <int> similarity-workers: 1;
|
||||
in property <float> similarity-minimum-score: 0.7;
|
||||
in property <int> similarity-max-tracks-per-artist: 5;
|
||||
in property <bool> similarity-federation-consent;
|
||||
in property <string> similarity-status-phase: "disabled";
|
||||
in property <string> similarity-status-progress: "0 / 0";
|
||||
in property <string> similarity-status-storage: "0 vectors";
|
||||
in property <string> similarity-status-current;
|
||||
in property <string> selected-language: "English";
|
||||
in property <[string]> available-languages;
|
||||
in property <[ReleaseView]> releases;
|
||||
@@ -44,8 +88,15 @@ export component AppWindow inherits Window {
|
||||
in property <string> detail-release-type;
|
||||
in property <image> detail-artwork;
|
||||
in property <bool> detail-has-artwork;
|
||||
in property <[TrackView]> tracks;
|
||||
in property <[TrackView]> history-tracks;
|
||||
in property <[TrackView]> similarity-tracks;
|
||||
in property <string> similarity-source-title;
|
||||
in property <bool> similarity-pending;
|
||||
in property <string> similarity-error;
|
||||
in property <[QueueView]> queue-items;
|
||||
private property <int> queue-drag-source: -1;
|
||||
private property <int> queue-drag-target: -1;
|
||||
private property <length> queue-drag-offset: 0px;
|
||||
in property <[PlaylistView]> playlists;
|
||||
in property <[TrackView]> playlist-tracks;
|
||||
in property <string> playlist-title;
|
||||
@@ -63,6 +114,7 @@ export component AppWindow inherits Window {
|
||||
in property <[ArtistView]> search-artists;
|
||||
in property <[ReleaseView]> search-releases;
|
||||
in property <bool> federation-status-busy;
|
||||
in property <bool> federation-status-running;
|
||||
in property <string> federation-status-text: "Federation ready";
|
||||
in property <string> federation-debug-node: "Stopped";
|
||||
in property <string> federation-debug-peers: "0 connected · 0 known";
|
||||
@@ -125,6 +177,8 @@ export component AppWindow inherits Window {
|
||||
callback play-track(string);
|
||||
callback play-track-context(string, string);
|
||||
callback play-queue-item(string);
|
||||
callback move-queue-item(string, int);
|
||||
callback remove-queue-item(string);
|
||||
callback track-action(string, string);
|
||||
callback open-playlist(string);
|
||||
callback create-playlist(string);
|
||||
@@ -141,6 +195,14 @@ export component AppWindow inherits Window {
|
||||
callback choose-library-path;
|
||||
callback federation-changed(bool);
|
||||
callback save-federated-on-listen-changed(bool);
|
||||
callback similarity-enabled-changed(bool);
|
||||
callback similarity-model-changed(string);
|
||||
callback similarity-profile-changed(string);
|
||||
callback similarity-workers-changed(int);
|
||||
callback similarity-minimum-score-changed(float);
|
||||
callback similarity-max-tracks-per-artist-changed(int);
|
||||
callback similarity-federation-consent-changed(bool);
|
||||
callback clear-similarity;
|
||||
callback language-changed(string);
|
||||
callback dismiss-error;
|
||||
callback close-track-info;
|
||||
@@ -171,6 +233,7 @@ export component AppWindow inherits Window {
|
||||
NavButton { label: root.home-label; icon-source: @image-url("../assets/home.svg"); active: root.active-screen == "home"; clicked => root.navigate("home"); }
|
||||
NavButton { label: root.search-label; icon-source: @image-url("../assets/search.svg"); active: root.active-screen == "search"; clicked => root.navigate("search"); }
|
||||
NavButton { label: root.library-label; icon-source: @image-url("../assets/library.svg"); active: root.active-screen == "library"; clicked => root.navigate("library"); }
|
||||
NavButton { label: root.history-label; icon-source: @image-url("../assets/history.svg"); active: root.active-screen == "history"; clicked => root.navigate("history"); }
|
||||
Rectangle { height: 12px; background: transparent; }
|
||||
HorizontalLayout {
|
||||
height: 28px;
|
||||
@@ -197,14 +260,6 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
NavButton { label: "Settings"; icon-source: @image-url("../assets/settings.svg"); active: root.settings-open; clicked => root.toggle-settings(); }
|
||||
Rectangle {
|
||||
height: 58px; border-radius: 9px; background: #171a22;
|
||||
HorizontalLayout {
|
||||
padding: 10px; spacing: 10px;
|
||||
Rectangle { width: 34px; height: 34px; border-radius: 17px; background: #353a49; Text { text: "F"; color: #fff; font-weight: 700; horizontal-alignment: center; vertical-alignment: center; } }
|
||||
VerticalLayout { alignment: center; Text { text: "Local library"; color: #e9eaf0; font-size: 12px; font-weight: 600; } Text { text: "Desktop player"; color: #777d8d; font-size: 10px; } }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
new-playlist-popup := PopupWindow {
|
||||
@@ -269,7 +324,7 @@ export component AppWindow inherits Window {
|
||||
border-width: 1px;
|
||||
HorizontalLayout {
|
||||
padding-left: 10px; padding-right: 10px; spacing: 6px;
|
||||
Text { text: root.federation-status-busy ? "◌" : "●"; color: root.federation-status-busy ? #55dbaa : #697080; font-size: 10px; vertical-alignment: center; }
|
||||
FederationStateIndicator { busy: root.federation-status-busy; running: root.federation-status-running; }
|
||||
status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
|
||||
}
|
||||
}
|
||||
@@ -285,9 +340,18 @@ export component AppWindow inherits Window {
|
||||
Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; }
|
||||
Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; }
|
||||
ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); }
|
||||
Rectangle { height: 8px; background: transparent; }
|
||||
Text { text: root.recent-label; color: #f5f6fa; font-size: 18px; font-weight: 750; }
|
||||
TrackList { tracks: root.tracks; play(key) => root.play-track-context("recent", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.active-screen == "history": ScrollView {
|
||||
viewport-width: self.width;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
width: parent.width; spacing: 17px; alignment: start;
|
||||
Text { height: 46px; vertical-stretch: 0; text: root.history-label; color: #f5f6fa; font-size: 30px; font-weight: 800; vertical-alignment: center; }
|
||||
Text { height: 18px; vertical-stretch: 0; text: root.history-tracks.length + (root.history-tracks.length == 1 ? " listen" : " listens"); color: #858b9c; font-size: 12px; vertical-alignment: center; }
|
||||
TrackList { tracks: root.history-tracks; show-artwork: true; play(key) => root.play-track-context("history", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +366,20 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
if root.active-screen == "similarity": ScrollView {
|
||||
viewport-width: self.width;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
width: parent.width; spacing: 17px; alignment: start;
|
||||
Text { height: 46px; vertical-stretch: 0; text: "Similar to “" + root.similarity-source-title + "”"; color: #f5f6fa; font-size: 30px; font-weight: 800; overflow: elide; vertical-alignment: center; }
|
||||
if root.similarity-pending: Text { height: 22px; text: "Searching local index and federation…"; color: #55dbaa; font-size: 12px; vertical-alignment: center; }
|
||||
if root.similarity-error != "": Text { height: 40px; text: root.similarity-error; color: #ff8d98; font-size: 12px; wrap: word-wrap; }
|
||||
if !root.similarity-pending && root.similarity-error == "" && root.similarity-tracks.length == 0: Text { height: 28px; text: "No matching tracks found."; color: #858b9c; font-size: 12px; vertical-alignment: center; }
|
||||
TrackList { tracks: root.similarity-tracks; show-artwork: true; play(key) => root.play-track-context("similarity", key); action(action, key) => root.track-action(action, key); open-artist(key) => root.open-artist(key); open-release(key) => root.open-release(key); }
|
||||
}
|
||||
}
|
||||
|
||||
if root.active-screen == "playlist": ScrollView {
|
||||
viewport-width: self.width;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
@@ -405,7 +483,7 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
|
||||
if root.queue-open: queue-panel := Rectangle {
|
||||
width: 292px;
|
||||
width: 370px;
|
||||
background: #11141b;
|
||||
border-color: #252936;
|
||||
border-width: 1px;
|
||||
@@ -425,24 +503,132 @@ export component AppWindow inherits Window {
|
||||
viewport-width: self.width;
|
||||
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
mouse-drag-pan-enabled: root.queue-drag-source < 0;
|
||||
queue-list := Rectangle {
|
||||
width: parent.width;
|
||||
spacing: 4px;
|
||||
alignment: start;
|
||||
for item in root.queue-items: Rectangle {
|
||||
height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent;
|
||||
TouchArea { double-clicked => root.play-queue-item(item.key); }
|
||||
HorizontalLayout {
|
||||
padding: 7px; spacing: 10px;
|
||||
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
|
||||
Rectangle {
|
||||
width: 190px; height: parent.height; background: transparent; clip: true;
|
||||
VerticalLayout {
|
||||
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
|
||||
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
|
||||
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
|
||||
height: root.queue-items.length > 0 ? root.queue-items.length * 56px - 4px : 0px;
|
||||
background: transparent;
|
||||
for item[index] in root.queue-items: queue-row := Rectangle {
|
||||
private property <int> visual-index: root.queue-drag-source < 0 ? index
|
||||
: index == root.queue-drag-source ? index
|
||||
: root.queue-drag-source < root.queue-drag-target && index > root.queue-drag-source && index <= root.queue-drag-target ? index - 1
|
||||
: root.queue-drag-target < root.queue-drag-source && index >= root.queue-drag-target && index < root.queue-drag-source ? index + 1
|
||||
: index;
|
||||
x: 0px;
|
||||
y: self.visual-index * 56px;
|
||||
width: parent.width;
|
||||
height: 52px;
|
||||
background: transparent;
|
||||
animate y { duration: 110ms; easing: ease-out; }
|
||||
queue-drag := TouchArea {
|
||||
private property <length> start-y;
|
||||
mouse-cursor: self.pressed ? grabbing : grab;
|
||||
pointer-event(event) => {
|
||||
if event.kind == PointerEventKind.cancel {
|
||||
if root.queue-drag-source == index {
|
||||
root.queue-drag-source = -1;
|
||||
root.queue-drag-target = -1;
|
||||
root.queue-drag-offset = 0px;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if event.button != PointerEventButton.left {
|
||||
return;
|
||||
}
|
||||
if event.kind == PointerEventKind.down {
|
||||
self.start-y = self.mouse-y;
|
||||
root.queue-drag-source = index;
|
||||
root.queue-drag-target = index;
|
||||
root.queue-drag-offset = 0px;
|
||||
} else if event.kind == PointerEventKind.up {
|
||||
if root.queue-drag-source == index && root.queue-drag-target != index {
|
||||
root.move-queue-item(item.key, root.queue-drag-target);
|
||||
}
|
||||
root.queue-drag-source = -1;
|
||||
root.queue-drag-target = -1;
|
||||
root.queue-drag-offset = 0px;
|
||||
}
|
||||
}
|
||||
moved => {
|
||||
if self.pressed && root.queue-drag-source == index {
|
||||
root.queue-drag-offset = self.mouse-y - self.start-y;
|
||||
root.queue-drag-target = max(0, min(root.queue-items.length - 1, index + round(root.queue-drag-offset / 56px)));
|
||||
}
|
||||
}
|
||||
double-clicked => root.play-queue-item(item.key);
|
||||
}
|
||||
row-visual := Rectangle {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
opacity: root.queue-drag-source == index ? 0 : 1;
|
||||
border-radius: 7px;
|
||||
background: item.active ? #242a35 : queue-drag.has-hover ? #1b1f29 : transparent;
|
||||
HorizontalLayout {
|
||||
padding: 7px; spacing: 10px;
|
||||
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
|
||||
Rectangle {
|
||||
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
|
||||
VerticalLayout {
|
||||
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
|
||||
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
|
||||
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
|
||||
}
|
||||
}
|
||||
TrackActionButton { active: item.liked; icon-source: @image-url("../assets/heart.svg"); clicked => root.track-action("like", item.track-key); }
|
||||
TrackActionButton { icon-source: @image-url("../assets/more.svg"); clicked => queue-menu.show(); }
|
||||
IconButton { width: 28px; height: 28px; icon-source: @image-url("../assets/close.svg"); clicked => root.remove-queue-item(item.key); }
|
||||
}
|
||||
}
|
||||
queue-menu := PopupWindow {
|
||||
x: queue-row.width - self.width - 8px;
|
||||
y: 46px;
|
||||
width: 184px;
|
||||
height: 118px;
|
||||
close-policy: close-on-click-outside;
|
||||
Rectangle {
|
||||
border-radius: 8px;
|
||||
background: #222631;
|
||||
border-color: #3a3f4d;
|
||||
border-width: 1px;
|
||||
drop-shadow-color: #00000080;
|
||||
drop-shadow-blur: 14px;
|
||||
drop-shadow-offset-y: 4px;
|
||||
VerticalLayout {
|
||||
padding: 6px; spacing: 1px;
|
||||
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { queue-menu.close(); root.track-action("information", item.track-key); } }
|
||||
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { queue-menu.close(); root.track-action("similar", item.track-key); } }
|
||||
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { queue-menu.close(); root.track-action("playlist", item.track-key); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.queue-drag-source >= 0: drag-preview := Rectangle {
|
||||
private property <QueueView> item: root.queue-items[root.queue-drag-source];
|
||||
x: 0px;
|
||||
y: max(0px, min(parent.height - self.height, root.queue-drag-source * 56px + root.queue-drag-offset));
|
||||
width: parent.width;
|
||||
height: 52px;
|
||||
border-radius: 7px;
|
||||
background: #29362f;
|
||||
border-width: 1px;
|
||||
border-color: #55dbaa;
|
||||
drop-shadow-color: #000000a0;
|
||||
drop-shadow-blur: 16px;
|
||||
drop-shadow-offset-y: 5px;
|
||||
HorizontalLayout {
|
||||
padding: 7px; spacing: 10px;
|
||||
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if drag-preview.item.has-artwork: Image { source: drag-preview.item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
|
||||
Rectangle {
|
||||
horizontal-stretch: 1; height: parent.height; background: transparent; clip: true;
|
||||
VerticalLayout {
|
||||
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
|
||||
MarqueeText { width: parent.width; height: 18px; label: drag-preview.item.title; text-color: drag-preview.item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
|
||||
Text { width: parent.width; height: 15px; text: drag-preview.item.artist; color: #777d8d; font-size: 10px; overflow: elide; vertical-alignment: center; }
|
||||
}
|
||||
}
|
||||
Rectangle { width: 28px; height: 28px; border-radius: 6px; background: drag-preview.item.liked ? #255845 : transparent; Image { source: @image-url("../assets/heart.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
|
||||
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/more.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
|
||||
Rectangle { width: 28px; height: 28px; Image { source: @image-url("../assets/close.svg"); width: 16px; height: 16px; x: 6px; y: 6px; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,6 +751,28 @@ export component AppWindow inherits Window {
|
||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||
VerticalLayout {
|
||||
spacing: 4px;
|
||||
for pairing in root.pending-pairings: Rectangle {
|
||||
height: pairing.group-conflict ? 142px : 76px; border-radius: 7px; background: #252a35;
|
||||
VerticalLayout {
|
||||
padding: 8px; spacing: 5px;
|
||||
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
|
||||
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
|
||||
if pairing.group-conflict: Text { text: pairing.group-summary; color: #d9a9d0; font-size: 9px; overflow: elide; }
|
||||
if pairing.group-conflict: Text { text: "Join theirs to keep its current peers syncing."; color: #9ca4b4; font-size: 9px; }
|
||||
if pairing.group-conflict: Text { text: "Keep this group moves only this device; its old peers stop syncing."; color: #9ca4b4; font-size: 9px; }
|
||||
if pairing.group-conflict: HorizontalLayout {
|
||||
spacing: 7px;
|
||||
RecommendedButton { text: "Join their group"; clicked => root.answer-pairing(pairing.request-id, true, true); }
|
||||
Button { text: "Keep this group"; clicked => root.answer-pairing(pairing.request-id, true, false); }
|
||||
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
|
||||
}
|
||||
if !pairing.group-conflict: HorizontalLayout {
|
||||
spacing: 7px;
|
||||
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, false); }
|
||||
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
|
||||
}
|
||||
}
|
||||
}
|
||||
for device in root.connected-devices: DeviceRow {
|
||||
visible: !device.active;
|
||||
height: !device.active ? 48px : 0px;
|
||||
@@ -572,23 +780,31 @@ export component AppWindow inherits Window {
|
||||
selectable: !device.revoked && (device.online || device.is-self);
|
||||
selected(id) => root.select-device(id);
|
||||
}
|
||||
for pairing in root.pending-pairings: Rectangle {
|
||||
height: 76px; border-radius: 7px; background: #252a35;
|
||||
VerticalLayout {
|
||||
padding: 8px; spacing: 5px;
|
||||
Text { text: pairing.name + " wants to connect"; color: #f0f1f5; font-size: 11px; font-weight: 650; overflow: elide; }
|
||||
Text { text: pairing.details; color: #7f8797; font-size: 9px; overflow: elide; }
|
||||
HorizontalLayout {
|
||||
spacing: 7px;
|
||||
Button { text: "Accept"; clicked => root.answer-pairing(pairing.request-id, true, pairing.group-conflict); }
|
||||
Button { text: "Deny"; clicked => root.answer-pairing(pairing.request-id, false, false); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: #303541; }
|
||||
if root.device-invite != "": Text { text: root.device-invite; color: #9ca4b4; font-size: 9px; wrap: word-wrap; max-height: 36px; overflow: elide; }
|
||||
if root.device-invite != "": HorizontalLayout {
|
||||
height: 32px; spacing: 8px;
|
||||
invite-code-frame := Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
min-width: 0px;
|
||||
height: 32px;
|
||||
clip: true;
|
||||
invite-code := LineEdit {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
text: root.device-invite;
|
||||
read-only: true;
|
||||
}
|
||||
}
|
||||
Button {
|
||||
text: "Copy";
|
||||
clicked => {
|
||||
invite-code.select-all();
|
||||
invite-code.copy();
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
spacing: 8px;
|
||||
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
|
||||
@@ -705,7 +921,7 @@ export component AppWindow inherits Window {
|
||||
spacing: 8px;
|
||||
LineEdit {
|
||||
text: root.library-path;
|
||||
placeholder-text: "~/Music/Furumi";
|
||||
placeholder-text: "Choose a music directory";
|
||||
horizontal-stretch: 1;
|
||||
edited => root.library-path-changed(self.text);
|
||||
}
|
||||
@@ -747,6 +963,109 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: #2a2e38; }
|
||||
Text { height: 22px; text: "Similarity search"; color: #f0f2f7; font-size: 15px; font-weight: 750; vertical-alignment: center; }
|
||||
HorizontalLayout {
|
||||
height: 52px;
|
||||
VerticalLayout {
|
||||
Text { text: "Find similar tracks"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
|
||||
Text { text: "Build a private local audio index. Downloads the selected model when enabled."; color: #818797; font-size: 11px; }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
CheckBox {
|
||||
text: "Enabled";
|
||||
checked: root.similarity-enabled;
|
||||
toggled => root.similarity-enabled-changed(self.checked);
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: 44px; spacing: 12px;
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
Text { text: "Embedding model"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
|
||||
Text { text: "CC BY-NC-SA 4.0 (or proprietary from MTG)"; color: #818797; font-size: 10px; }
|
||||
}
|
||||
ComboBox {
|
||||
width: 244px;
|
||||
model: root.similarity-models;
|
||||
current-value: root.similarity-model;
|
||||
selected(value) => root.similarity-model-changed(value);
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: 44px; spacing: 12px;
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
Text { text: "Preprocessing profile"; color: #e8eaf0; font-size: 12px; font-weight: 650; }
|
||||
Text { text: "Full track; long tracks use balanced windows."; color: #818797; font-size: 10px; }
|
||||
}
|
||||
ComboBox {
|
||||
width: 244px;
|
||||
model: root.similarity-profiles;
|
||||
current-value: root.similarity-profile;
|
||||
selected(value) => root.similarity-profile-changed(value);
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
spacing: 6px;
|
||||
HorizontalLayout {
|
||||
height: 20px;
|
||||
Text { horizontal-stretch: 1; text: "Minimum similarity"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
|
||||
Text { text: round(root.similarity-minimum-score * 100) + "%"; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
|
||||
}
|
||||
Slider { height: 18px; minimum: 0; maximum: 1; value: root.similarity-minimum-score; changed(value) => root.similarity-minimum-score-changed(value); }
|
||||
}
|
||||
VerticalLayout {
|
||||
spacing: 6px;
|
||||
HorizontalLayout {
|
||||
height: 20px;
|
||||
Text { horizontal-stretch: 1; text: "Maximum tracks per artist"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
|
||||
Text { text: root.similarity-max-tracks-per-artist; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
|
||||
}
|
||||
Slider { height: 18px; minimum: 1; maximum: 50; value: root.similarity-max-tracks-per-artist; changed(value) => root.similarity-max-tracks-per-artist-changed(round(value)); }
|
||||
}
|
||||
VerticalLayout {
|
||||
spacing: 6px;
|
||||
HorizontalLayout {
|
||||
height: 20px;
|
||||
Text { horizontal-stretch: 1; text: "Background workers"; color: #e8eaf0; font-size: 12px; font-weight: 650; vertical-alignment: center; }
|
||||
Text { text: root.similarity-workers; color: #aeb4c2; font-size: 11px; vertical-alignment: center; }
|
||||
}
|
||||
Slider { height: 18px; minimum: 1; maximum: 16; value: root.similarity-workers; changed(value) => root.similarity-workers-changed(round(value)); }
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: 62px;
|
||||
VerticalLayout {
|
||||
Text { text: "Search federation too"; color: #e8eaf0; font-size: 13px; font-weight: 650; }
|
||||
Text { text: "Send an anonymous numeric embedding to peers. No account identity is included."; color: #818797; font-size: 10px; }
|
||||
Text { text: "A peer may infer the kind of music being searched."; color: #d4ad62; font-size: 10px; }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
CheckBox {
|
||||
text: "I agree";
|
||||
checked: root.similarity-federation-consent;
|
||||
toggled => root.similarity-federation-consent-changed(self.checked);
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
min-height: 112px; max-height: 112px; vertical-stretch: 0;
|
||||
border-radius: 9px; background: #11151d; border-width: 1px; border-color: #2c3240;
|
||||
VerticalLayout {
|
||||
padding: 11px; spacing: 5px;
|
||||
HorizontalLayout {
|
||||
height: 20px;
|
||||
Text { horizontal-stretch: 1; text: "Similarity index"; color: #dfe2ea; font-size: 11px; font-weight: 700; vertical-alignment: center; }
|
||||
Text { text: root.similarity-status-phase; color: root.similarity-enabled ? #55dbaa : #777d8d; font-size: 10px; vertical-alignment: center; }
|
||||
}
|
||||
Text { height: 17px; text: "Progress " + root.similarity-status-progress + " Stored " + root.similarity-status-storage; color: #8f96a6; font-size: 10px; overflow: elide; vertical-alignment: center; }
|
||||
Text { height: 17px; text: root.similarity-status-current; color: #697080; font-size: 10px; overflow: elide; vertical-alignment: center; }
|
||||
HorizontalLayout {
|
||||
height: 28px;
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
Button { text: "Clear stored embeddings"; clicked => root.clear-similarity(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
height: 52px;
|
||||
VerticalLayout {
|
||||
|
||||
@@ -15,6 +15,49 @@ export component IconButton inherits Rectangle {
|
||||
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); }
|
||||
}
|
||||
|
||||
export component RecommendedButton inherits Rectangle {
|
||||
in property <string> text;
|
||||
in property <bool> enabled: true;
|
||||
callback clicked;
|
||||
min-width: label.preferred-width + 24px;
|
||||
min-height: 32px;
|
||||
horizontal-stretch: 0;
|
||||
vertical-stretch: 0;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-color: root.enabled ? #65d7a9 : #49645a;
|
||||
background: !root.enabled ? #31443d : touch.pressed ? #267657 : touch.has-hover ? #3aa77d : #318e6b;
|
||||
opacity: root.enabled ? 1 : 0.55;
|
||||
accessible-role: button;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-label: root.text;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
forward-focus: focus;
|
||||
|
||||
label := Text {
|
||||
text: root.text;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
clicked => root.clicked();
|
||||
}
|
||||
focus := FocusScope {
|
||||
enabled: root.enabled;
|
||||
key-pressed(event) => {
|
||||
if event.text == " " || event.text == "\n" {
|
||||
root.clicked();
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component DeviceRow inherits Rectangle {
|
||||
in property <DeviceView> item;
|
||||
in property <bool> selectable: false;
|
||||
@@ -146,9 +189,14 @@ export component PlaylistNavButton inherits Rectangle {
|
||||
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
|
||||
HorizontalLayout {
|
||||
padding-left: 10px; padding-right: 8px; spacing: 9px;
|
||||
Image {
|
||||
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
|
||||
width: 15px; height: 15px;
|
||||
Rectangle {
|
||||
width: 15px;
|
||||
background: transparent;
|
||||
Image {
|
||||
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg");
|
||||
width: 15px; height: 15px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
}
|
||||
}
|
||||
Text { horizontal-stretch: 1; text: root.item.title; color: root.active ? #f5f6fa : #a4a9b7; font-size: 12px; overflow: elide; vertical-alignment: center; }
|
||||
}
|
||||
@@ -629,7 +677,7 @@ export component TrackRow inherits Rectangle {
|
||||
VerticalLayout {
|
||||
padding: 6px; spacing: 1px;
|
||||
TrackMenuItem { icon-source: @image-url("../assets/info.svg"); label: "Track information"; clicked => { menu.close(); root.action("information", root.item.key); } }
|
||||
TrackMenuItem { icon-source: @image-url("../assets/share.svg"); label: "Share"; clicked => { menu.close(); root.action("share", root.item.key); } }
|
||||
TrackMenuItem { icon-source: @image-url("../assets/search.svg"); label: "Find similar"; clicked => { menu.close(); root.action("similar", root.item.key); } }
|
||||
TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +55,14 @@ export struct TrackView {
|
||||
|
||||
export struct QueueView {
|
||||
key: string,
|
||||
track-key: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
artist-key: string,
|
||||
release: string,
|
||||
release-key: string,
|
||||
active: bool,
|
||||
liked: bool,
|
||||
artwork: image,
|
||||
has-artwork: bool,
|
||||
}
|
||||
@@ -87,4 +89,5 @@ export struct PairingView {
|
||||
name: string,
|
||||
details: string,
|
||||
group-conflict: bool,
|
||||
group-summary: string,
|
||||
}
|
||||
|
||||
@@ -35,5 +35,10 @@ pkgs.mkShell {
|
||||
xorg.libXrandr
|
||||
]);
|
||||
|
||||
# The software renderer is the reliable default in a NixOS development
|
||||
# shell: it avoids mixing the shell's Vulkan loader with the host's graphics
|
||||
# driver stack. Override this when explicitly testing another renderer.
|
||||
SLINT_BACKEND = "winit-software";
|
||||
|
||||
RUST_BACKTRACE = "1";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user