9 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 69bba8e444 Fixed UI bugs 2026-08-14 02:22:03 +01:00
Aleksandr Bogomiakov 0d7c9b9f5e Fixed UI bugs 2026-08-14 02:14:46 +01:00
Aleksandr Bogomiakov 2dc40c2c3f fixed ci 2026-08-13 16:50:06 +01:00
Aleksandr Bogomiakov f6d8cb0631 Fix rfd portal runtime feature 2026-08-13 16:19:15 +01:00
Aleksandr Bogomiakov 728ca708b2 Added appimage 2026-08-13 15:34:07 +01:00
Ultradesu aa392e8f7f fix lock 2026-08-13 12:12:10 +01:00
Ultradesu 4714cd77db CI 2026-08-13 12:01:27 +01:00
Ultradesu c2ca09aecf It works 2026-08-13 11:55:35 +01:00
Ultradesu e26159f85f Added app icon 2026-08-12 01:56:21 +01:00
32 changed files with 5015 additions and 188 deletions
+250
View File
@@ -0,0 +1,250 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs:
build:
name: Build ${{ matrix.asset_name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# AppImages must be built on the oldest supported distribution so
# that their glibc requirement remains compatible with newer ones.
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
asset_name: furumi-desktop-linux-x86_64
archive_ext: AppImage
binary_name: furumi-desktop
- os: macos-latest
target: aarch64-apple-darwin
asset_name: furumi-desktop-macos-aarch64
archive_ext: tar.gz
binary_name: furumi-desktop
- os: windows-latest
target: x86_64-pc-windows-msvc
asset_name: furumi-desktop-windows-x86_64
archive_ext: zip
binary_name: furumi-desktop.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libegl1-mesa-dev \
libfontconfig1-dev \
libfreetype-dev \
libgl1-mesa-dev \
libudev-dev \
libwayland-dev \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libxcursor-dev \
libxi-dev \
libxrandr-dev \
pkg-config
- name: Install Rust target
shell: bash
run: rustup target add "${{ matrix.target }}"
- name: Show Rust version
shell: bash
run: |
rustc --version --verbose
cargo --version
- name: Fetch locked dependencies
shell: bash
run: cargo fetch --locked --target "${{ matrix.target }}"
- name: Build desktop application
shell: bash
run: |
cargo build \
--release \
--locked \
--offline \
--package furumi-desktop \
--target "${{ matrix.target }}"
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}"
package_dir="dist/${{ matrix.asset_name }}"
binary="target/${{ matrix.target }}/release/${{ matrix.binary_name }}"
if [[ "${{ runner.os }}" == "Linux" ]]; then
app_dir="dist/FurumiDesktop.AppDir"
appimage_runtime="$RUNNER_TEMP/appimage-runtime-x86_64"
linuxdeploy="$RUNNER_TEMP/linuxdeploy-x86_64.AppImage"
linuxdeploy_dir="$RUNNER_TEMP/linuxdeploy"
linuxdeploy_version="1-alpha-20251107-1"
linuxdeploy_sha256="c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d"
runtime_version="20251108"
runtime_sha256="2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d"
mkdir -p "$app_dir/usr/share/doc/furumi-desktop"
cp README.md LICENSE "$app_dir/usr/share/doc/furumi-desktop/"
curl --fail --location --retry 3 --silent --show-error \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/${linuxdeploy_version}/linuxdeploy-x86_64.AppImage" \
--output "$linuxdeploy"
echo "${linuxdeploy_sha256} ${linuxdeploy}" | sha256sum --check
chmod +x "$linuxdeploy"
curl --fail --location --retry 3 --silent --show-error \
"https://github.com/AppImage/type2-runtime/releases/download/${runtime_version}/runtime-x86_64" \
--output "$appimage_runtime"
echo "${runtime_sha256} ${appimage_runtime}" | sha256sum --check
APPIMAGE_EXTRACT_AND_RUN=1 "$linuxdeploy" \
--appdir "$app_dir" \
--executable "$binary" \
--desktop-file packaging/linux/furumi-desktop.desktop \
--icon-file crates/ui/assets/federation.svg \
--icon-filename furumi-desktop
mkdir -p "$linuxdeploy_dir"
(
cd "$linuxdeploy_dir"
"$linuxdeploy" --appimage-extract >/dev/null
)
appimagetool="$linuxdeploy_dir/squashfs-root/plugins/linuxdeploy-plugin-appimage/appimagetool-prefix/AppRun"
ARCH=x86_64 VERSION="$version" "$appimagetool" \
--no-appstream \
--runtime-file "$appimage_runtime" \
"$app_dir" \
"$archive_name"
chmod +x "$archive_name"
"./$archive_name" --appimage-extract >/dev/null
test -x squashfs-root/AppRun
test -x squashfs-root/usr/bin/furumi-desktop
test -f squashfs-root/usr/share/applications/furumi-desktop.desktop
test -f squashfs-root/usr/share/icons/hicolor/scalable/apps/furumi-desktop.svg
rm -rf squashfs-root
elif [[ "${{ runner.os }}" == "macOS" ]]; then
mkdir -p "$package_dir"
app_dir="$package_dir/Furumi Desktop.app"
mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources"
cp "$binary" "$app_dir/Contents/MacOS/furumi-desktop"
iconset="$RUNNER_TEMP/furumi.iconset"
mkdir -p "$iconset"
sips -s format png -z 16 16 crates/ui/assets/federation.svg --out "$iconset/icon_16x16.png" >/dev/null
sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_16x16@2x.png" >/dev/null
sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_32x32.png" >/dev/null
sips -s format png -z 64 64 crates/ui/assets/federation.svg --out "$iconset/icon_32x32@2x.png" >/dev/null
sips -s format png -z 128 128 crates/ui/assets/federation.svg --out "$iconset/icon_128x128.png" >/dev/null
sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_128x128@2x.png" >/dev/null
sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_256x256.png" >/dev/null
sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_256x256@2x.png" >/dev/null
sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_512x512.png" >/dev/null
sips -s format png -z 1024 1024 crates/ui/assets/federation.svg --out "$iconset/icon_512x512@2x.png" >/dev/null
iconutil -c icns "$iconset" -o "$app_dir/Contents/Resources/furumi.icns"
cat > "$app_dir/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Furumi Desktop</string>
<key>CFBundleExecutable</key>
<string>furumi-desktop</string>
<key>CFBundleIdentifier</key>
<string>cy.hexor.furumi-desktop</string>
<key>CFBundleIconFile</key>
<string>furumi.icns</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Furumi Desktop</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
plutil -lint "$app_dir/Contents/Info.plist"
codesign --force --deep --sign - "$app_dir"
codesign --verify --deep --strict "$app_dir"
else
mkdir -p "$package_dir"
cp "$binary" "$package_dir/"
fi
if [[ "${{ runner.os }}" == "Windows" ]]; then
cp README.md LICENSE "$package_dir/"
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
elif [[ "${{ runner.os }}" == "macOS" ]]; then
cp README.md LICENSE "$package_dir/"
tar -C dist -czf "$archive_name" "${{ matrix.asset_name }}"
fi
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}-*
if-no-files-found: error
publish:
name: Publish release assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \
--title "furumi-desktop ${RELEASE_TAG}" \
--generate-notes \
--verify-tag
+47 -7
View File
@@ -65,10 +65,50 @@ the common placeholder and never performs filesystem or network I/O.
## Settings persistence ## Settings persistence
The backend stores settings in `furumi-desktop.sqlite3` under the platform The backend stores settings in `furumi-desktop.sqlite3` under the dedicated
application-data directory selected by `directories::ProjectDirs`. Schema `furumi-desktop` platform application-data directory selected by
changes are ordered migrations recorded in `schema_migrations`; each migration `directories::ProjectDirs`. Device identity, federation state, and caches use
runs in a transaction. The settings writer owns its SQLite connection on a that same desktop-specific namespace and are never shared with other Furumi
dedicated thread and coalesces bursts of edits before writing the latest full clients. Only the music library path returned by `furumi-library` is shared.
snapshot. The configured device name is also written to the connected-device Schema changes are ordered migrations recorded in `schema_migrations`; each
identity and published through the device-profile operation log. 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
+629 -13
View File
@@ -184,6 +184,12 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "anymap3"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.4.2"
@@ -250,6 +256,7 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"url", "url",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
@@ -525,7 +532,7 @@ dependencies = [
"log", "log",
"num-rational", "num-rational",
"num-traits", "num-traits",
"pastey", "pastey 0.1.1",
"rayon", "rayon",
"thiserror 2.0.20", "thiserror 2.0.20",
"v_frame", "v_frame",
@@ -614,6 +621,24 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.3" version = "0.10.3"
@@ -1434,6 +1459,17 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1579,6 +1615,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "downcast-rs"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]] [[package]]
name = "dpi" name = "dpi"
version = "0.1.2" version = "0.1.2"
@@ -1637,6 +1679,18 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "ed25519" name = "ed25519"
version = "3.0.0" version = "3.0.0"
@@ -1658,7 +1712,7 @@ dependencies = [
"ed25519", "ed25519",
"rand_core 0.10.1", "rand_core 0.10.1",
"serde", "serde",
"sha2", "sha2 0.11.0",
"signature", "signature",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -1755,6 +1809,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
@@ -1982,6 +2047,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
name = "float-ord"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.12.0" version = "0.12.0"
@@ -2110,7 +2181,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-application" name = "furumi-application"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"furumi-backend-api", "furumi-backend-api",
"furumi-domain", "furumi-domain",
@@ -2118,32 +2189,38 @@ dependencies = [
[[package]] [[package]]
name = "furumi-backend" name = "furumi-backend"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"blake3",
"directories", "directories",
"furumi-backend-api", "furumi-backend-api",
"furumi-domain", "furumi-domain",
"furumi-library", "furumi-library",
"futures-util",
"music-dht", "music-dht",
"reqwest 0.12.28",
"rodio", "rodio",
"rusqlite", "rusqlite",
"rustfft",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tract-onnx",
] ]
[[package]] [[package]]
name = "furumi-backend-api" name = "furumi-backend-api"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"furumi-domain", "furumi-domain",
] ]
[[package]] [[package]]
name = "furumi-desktop" name = "furumi-desktop"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"furumi-backend", "furumi-backend",
"furumi-ui", "furumi-ui",
@@ -2151,7 +2228,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-domain" name = "furumi-domain"
version = "0.1.0" version = "0.1.2"
[[package]] [[package]]
name = "furumi-library" name = "furumi-library"
@@ -2172,9 +2249,12 @@ dependencies = [
[[package]] [[package]]
name = "furumi-platform-desktop" name = "furumi-platform-desktop"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd", "rfd",
"souvlaki", "souvlaki",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -2182,7 +2262,7 @@ dependencies = [
[[package]] [[package]]
name = "furumi-ui" name = "furumi-ui"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"furumi-application", "furumi-application",
"furumi-backend", "furumi-backend",
@@ -2589,6 +2669,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"crunchy", "crunchy",
"num-traits",
"zerocopy", "zerocopy",
] ]
@@ -2631,6 +2712,8 @@ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash", "foldhash",
"serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2863,6 +2946,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots",
] ]
[[package]] [[package]]
@@ -3530,6 +3614,15 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "io-lifetimes" name = "io-lifetimes"
version = "1.0.11" version = "1.0.11"
@@ -3598,7 +3691,7 @@ dependencies = [
"portable-atomic", "portable-atomic",
"portmapper", "portmapper",
"rand 0.10.2", "rand 0.10.2",
"reqwest", "reqwest 0.13.4",
"rustc-hash 2.1.3", "rustc-hash 2.1.3",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
@@ -3713,7 +3806,7 @@ dependencies = [
"pin-project", "pin-project",
"postcard", "postcard",
"rand 0.10.2", "rand 0.10.2",
"reqwest", "reqwest 0.13.4",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
@@ -4196,6 +4289,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -4205,6 +4304,16 @@ dependencies = [
"regex-automata", "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]] [[package]]
name = "maybe-rayon" name = "maybe-rayon"
version = "0.1.1" version = "0.1.1"
@@ -4230,6 +4339,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.7.1" version = "0.7.1"
@@ -4248,6 +4363,16 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -4402,6 +4527,21 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" 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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -4603,6 +4743,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "noop_proc_macro" name = "noop_proc_macro"
version = "0.3.0" version = "0.3.0"
@@ -5403,6 +5552,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "1.0.0" version = "1.0.0"
@@ -5604,6 +5759,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "portmapper" name = "portmapper"
version = "0.19.1" version = "0.19.1"
@@ -5705,6 +5869,15 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -5752,6 +5925,29 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.13.4" version = "0.13.4"
@@ -5824,6 +6020,62 @@ dependencies = [
"memchr", "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]] [[package]]
name = "quote" name = "quote"
version = "1.0.47" version = "1.0.47"
@@ -5921,6 +6173,16 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" 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]] [[package]]
name = "rand_pcg" name = "rand_pcg"
version = "0.10.2" version = "0.10.2"
@@ -6007,6 +6269,12 @@ dependencies = [
"objc2-quartz-core 0.3.2", "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]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.12.0"
@@ -6121,6 +6389,47 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -6154,7 +6463,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@@ -6310,6 +6619,20 @@ dependencies = [
"semver", "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]] [[package]]
name = "rustix" name = "rustix"
version = "0.37.28" version = "0.37.28"
@@ -6455,6 +6778,19 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -6464,6 +6800,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.29" version = "0.1.29"
@@ -6632,6 +6977,18 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "serdect" name = "serdect"
version = "0.4.3" version = "0.4.3"
@@ -6659,6 +7016,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 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]] [[package]]
name = "sha2" name = "sha2"
version = "0.11.0" version = "0.11.0"
@@ -7116,6 +7484,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]] [[package]]
name = "strict-num" name = "strict-num"
version = "0.1.1" version = "0.1.1"
@@ -7125,6 +7499,16 @@ dependencies = [
"float-cmp", "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]] [[package]]
name = "strum" name = "strum"
version = "0.28.0" version = "0.28.0"
@@ -7662,6 +8046,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2 0.6.5", "socket2 0.6.5",
"tokio-macros", "tokio-macros",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -7911,6 +8296,217 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -7936,6 +8532,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -8280,6 +8882,19 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@@ -8300,7 +8915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8"
dependencies = [ dependencies = [
"cc", "cc",
"downcast-rs", "downcast-rs 1.2.1",
"rustix 1.1.4", "rustix 1.1.4",
"scoped-tls", "scoped-tls",
"smallvec", "smallvec",
@@ -9238,6 +9853,7 @@ dependencies = [
"rustix 1.1.4", "rustix 1.1.4",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid", "uuid",
+15 -2
View File
@@ -11,7 +11,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.2"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
license = "WTFPL" license = "WTFPL"
@@ -21,6 +21,8 @@ readme = "README.md"
[workspace.dependencies] [workspace.dependencies]
anyhow = "1.0.102" anyhow = "1.0.102"
blake3 = "1"
futures-util = "0.3.32"
slint = { version = "=1.17.1", default-features = false, features = [ slint = { version = "=1.17.1", default-features = false, features = [
"backend-winit", "backend-winit",
"renderer-femtovg", "renderer-femtovg",
@@ -36,7 +38,7 @@ music-dht = "0.4.0"
serde_json = "1.0.150" serde_json = "1.0.150"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
rusqlite = { version = "0.32.1", features = ["bundled"] } rusqlite = { version = "0.32.1", features = ["bundled"] }
rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal"] } rfd = { version = "0.15.4", default-features = false, features = ["xdg-portal", "tokio"] }
rodio = { version = "0.22.2", default-features = false, features = [ rodio = { version = "0.22.2", default-features = false, features = [
"playback", "playback",
"mp3", "mp3",
@@ -47,8 +49,19 @@ rodio = { version = "0.22.2", default-features = false, features = [
"symphonia-isomp4", "symphonia-isomp4",
"symphonia-alac", "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"] } souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
core-foundation = "0.10.1" core-foundation = "0.10.1"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", default-features = false, features = [
"NSApplication",
"NSImage",
"NSResponder",
] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSData"] }
windows-sys = { version = "0.61.2", features = ["Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] } windows-sys = { version = "0.61.2", features = ["Win32_System_LibraryLoader", "Win32_UI_WindowsAndMessaging"] }
furumi-application = { path = "crates/application" } furumi-application = { path = "crates/application" }
+11
View File
@@ -63,6 +63,17 @@ nix-shell
cargo run --bin furumi-desktop cargo run --bin furumi-desktop
``` ```
Linux releases are distributed as AppImages. Make the downloaded file
executable and run it directly:
```bash
chmod +x furumi-desktop-linux-x86_64-*.AppImage
./furumi-desktop-linux-x86_64-*.AppImage
```
On NixOS, enable AppImage support with `programs.appimage.enable` and
`programs.appimage.binfmt`, or launch it explicitly with `appimage-run`.
macOS and Windows require no additional system packages. Player settings are macOS and Windows require no additional system packages. Player settings are
available inside the application and are saved automatically. available inside the application and are saved automatically.
+210 -18
View File
@@ -1,7 +1,9 @@
//! Deterministic frontend state, reducers and UI projections. //! Deterministic frontend state, reducers and UI projections.
use furumi_backend_api::{BackendCommand, BackendSnapshot, PlaybackStatus, RequestId}; use furumi_backend_api::{
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey}; BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId,
};
use furumi_domain::{ArtistKey, QueueItemId, Release, ReleaseKey, Track, TrackKey};
use std::time::Duration; use std::time::Duration;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -17,7 +19,7 @@ pub struct Strings {
pub search: &'static str, pub search: &'static str,
pub library: &'static str, pub library: &'static str,
pub queue: &'static str, pub queue: &'static str,
pub recently_played: &'static str, pub listening_history: &'static str,
pub made_for_listening: &'static str, pub made_for_listening: &'static str,
pub empty_queue: &'static str, pub empty_queue: &'static str,
pub search_placeholder: &'static str, pub search_placeholder: &'static str,
@@ -29,7 +31,7 @@ pub const EN: Strings = Strings {
search: "Search", search: "Search",
library: "Your library", library: "Your library",
queue: "Queue", queue: "Queue",
recently_played: "Recently played", listening_history: "Listening history",
made_for_listening: "Made for listening", made_for_listening: "Made for listening",
empty_queue: "Your queue is empty", empty_queue: "Your queue is empty",
search_placeholder: "Artists, albums or tracks", search_placeholder: "Artists, albums or tracks",
@@ -50,6 +52,8 @@ pub enum Screen {
Home, Home,
Search, Search,
Library, Library,
History,
Similarity,
Artist(ArtistKey), Artist(ArtistKey),
Release(ReleaseKey, Option<ArtistKey>), Release(ReleaseKey, Option<ArtistKey>),
Playlist(i64), Playlist(i64),
@@ -105,6 +109,8 @@ pub enum UiAction {
TogglePlayback, TogglePlayback,
Next, Next,
Previous, Previous,
ToggleShuffle,
CycleRepeat,
Seek(f64), Seek(f64),
SetVolume(f32), SetVolume(f32),
PlayRelease { PlayRelease {
@@ -113,6 +119,13 @@ pub enum UiAction {
}, },
PlayTrack(TrackKey), PlayTrack(TrackKey),
PlayQueueItem(QueueItemId), PlayQueueItem(QueueItemId),
MoveQueueItem {
item_id: QueueItemId,
target_index: usize,
},
RemoveQueueItem(QueueItemId),
SearchSimilar(TrackKey),
ClearSimilarity,
PlayContext { PlayContext {
tracks: Vec<TrackKey>, tracks: Vec<TrackKey>,
selected: TrackKey, selected: TrackKey,
@@ -141,6 +154,13 @@ pub enum UiAction {
LibraryPathChanged(String), LibraryPathChanged(String),
FederationChanged(bool), FederationChanged(bool),
SaveFederatedOnListenChanged(bool), SaveFederatedOnListenChanged(bool),
SimilarityEnabledChanged(bool),
SimilarityModelChanged(String),
SimilarityProfileChanged(String),
SimilarityWorkersChanged(usize),
SimilarityMinimumScoreChanged(f32),
SimilarityMaxTracksPerArtistChanged(usize),
SimilarityFederationConsentChanged(bool),
LanguageChanged(String), LanguageChanged(String),
ShowTrackInfo(TrackKey), ShowTrackInfo(TrackKey),
CloseTrackInfo, CloseTrackInfo,
@@ -176,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); state.next_request_id = state.next_request_id.saturating_add(1);
let request_id = RequestId::new(state.next_request_id); let request_id = RequestId::new(state.next_request_id);
match screen { match screen {
Screen::Artist(key) => find_artist(state, &key).map_or_else(Vec::new, |artist| { Screen::Artist(key) => {
send(BackendCommand::LoadArtist { find_artist_name(state, &key).map_or_else(Vec::new, |name| {
request_id, send(BackendCommand::LoadArtist {
key, request_id,
name: artist.name.clone(), key,
name: name.to_owned(),
})
}) })
}), }
Screen::Release(key, preferred) => { Screen::Release(key, preferred) => {
find_release(state, &key).map_or_else(Vec::new, |release| { find_release(state, &key).map_or_else(Vec::new, |release| {
let selected_artist = preferred let selected_artist = preferred
@@ -194,8 +216,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
.find(|artist| &artist.key == preferred) .find(|artist| &artist.key == preferred)
.map(|artist| (artist.key.clone(), artist.name.clone())) .map(|artist| (artist.key.clone(), artist.name.clone()))
.or_else(|| { .or_else(|| {
find_artist(state, preferred) find_artist_name(state, preferred)
.map(|artist| (artist.key.clone(), artist.name.clone())) .map(|name| (preferred.clone(), name.to_owned()))
}) })
}) })
.or_else(|| { .or_else(|| {
@@ -258,6 +280,8 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
UiAction::TogglePlayback => send(BackendCommand::TogglePlayback), UiAction::TogglePlayback => send(BackendCommand::TogglePlayback),
UiAction::Next => send(BackendCommand::Next), UiAction::Next => send(BackendCommand::Next),
UiAction::Previous => send(BackendCommand::Previous), UiAction::Previous => send(BackendCommand::Previous),
UiAction::ToggleShuffle => send(BackendCommand::ToggleShuffle),
UiAction::CycleRepeat => send(BackendCommand::CycleRepeat),
UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }), UiAction::Seek(position_seconds) => send(BackendCommand::Seek { position_seconds }),
UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }), UiAction::SetVolume(volume) => send(BackendCommand::SetVolume { volume }),
UiAction::PlayRelease { release_id, start } => { UiAction::PlayRelease { release_id, start } => {
@@ -265,6 +289,26 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
} }
UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }), UiAction::PlayTrack(track) => send(BackendCommand::PlayTrack { track }),
UiAction::PlayQueueItem(item_id) => send(BackendCommand::PlayQueueItem { item_id }), 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 } => { UiAction::PlayContext { tracks, selected } => {
send(BackendCommand::PlayContext { tracks, selected }) send(BackendCommand::PlayContext { tracks, selected })
} }
@@ -356,6 +400,48 @@ pub fn reduce_action(state: &mut AppState, action: UiAction) -> Vec<Effect> {
state.backend.settings.clone(), 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) => { UiAction::LanguageChanged(language) => {
state.frontend.locale = Locale::En; state.frontend.locale = Locale::En;
state.backend.settings.language = language; state.backend.settings.language = language;
@@ -378,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> { fn find_artist_name<'a>(state: &'a AppState, key: &ArtistKey) -> Option<&'a str> {
match &state.backend.library { let library = match &state.backend.library {
furumi_backend_api::RemoteData::Ready(l) => Some(l), furumi_backend_api::RemoteData::Ready(l) => Some(l),
_ => None, _ => 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() if let Some(name) = library
.flat_map(|l| l.artists.iter()) .into_iter()
.chain(state.backend.search.results.artists.iter()) .flat_map(|library| library.featured_releases.iter())
.find(|a| &a.key == key) .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> { fn find_release<'a>(state: &'a AppState, key: &ReleaseKey) -> Option<&'a furumi_domain::Release> {
@@ -433,6 +568,8 @@ pub struct PlayerProjection {
pub duration: String, pub duration: String,
pub progress: f32, pub progress: f32,
pub volume: f32, pub volume: f32,
pub shuffle: bool,
pub repeat: PlaybackRepeat,
} }
impl AppState { impl AppState {
@@ -450,6 +587,8 @@ impl AppState {
duration: duration_label(duration), duration: duration_label(duration),
progress: progress_ratio(elapsed, duration), progress: progress_ratio(elapsed, duration),
volume: self.backend.playback.volume, volume: self.backend.playback.volume,
shuffle: self.backend.playback.shuffle,
repeat: self.backend.playback.repeat,
} }
} }
} }
@@ -506,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] #[test]
fn stale_backend_snapshots_are_ignored() { fn stale_backend_snapshots_are_ignored() {
let mut state = AppState::default(); let mut state = AppState::default();
@@ -553,6 +707,44 @@ mod tests {
assert_eq!(state.frontend.navigation_history, vec![Screen::Home]); 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] #[test]
fn release_lookup_keeps_the_preferred_artist_name_and_key_together() { fn release_lookup_keeps_the_preferred_artist_name_and_key_together() {
let mut state = AppState::default(); let mut state = AppState::default();
+114 -2
View File
@@ -27,12 +27,33 @@ pub enum PlaybackStatus {
Paused, Paused,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PlaybackRepeat {
#[default]
Off,
One,
All,
}
impl PlaybackRepeat {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Off => Self::All,
Self::All => Self::One,
Self::One => Self::Off,
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PlaybackSnapshot { pub struct PlaybackSnapshot {
pub status: PlaybackStatus, pub status: PlaybackStatus,
pub position_seconds: f64, pub position_seconds: f64,
pub duration_seconds: f64, pub duration_seconds: f64,
pub volume: f32, pub volume: f32,
pub shuffle: bool,
pub repeat: PlaybackRepeat,
} }
impl Default for PlaybackSnapshot { impl Default for PlaybackSnapshot {
@@ -42,6 +63,8 @@ impl Default for PlaybackSnapshot {
position_seconds: 0.0, position_seconds: 0.0,
duration_seconds: 0.0, duration_seconds: 0.0,
volume: 0.72, volume: 0.72,
shuffle: false,
repeat: PlaybackRepeat::Off,
} }
} }
} }
@@ -199,7 +222,7 @@ pub struct BuildInfoSnapshot {
pub protocols: Vec<VersionEntrySnapshot>, pub protocols: Vec<VersionEntrySnapshot>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq)]
pub struct SettingsSnapshot { pub struct SettingsSnapshot {
pub network_id: String, pub network_id: String,
pub device_name: String, pub device_name: String,
@@ -207,6 +230,54 @@ pub struct SettingsSnapshot {
pub federation_enabled: bool, pub federation_enabled: bool,
pub save_federated_on_listen: bool, pub save_federated_on_listen: bool,
pub language: String, 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 { impl Default for SettingsSnapshot {
@@ -214,14 +285,40 @@ impl Default for SettingsSnapshot {
Self { Self {
network_id: "furumi".into(), network_id: "furumi".into(),
device_name: String::new(), 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, federation_enabled: true,
save_federated_on_listen: true, save_federated_on_listen: true,
language: "English".into(), 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)] #[derive(Debug, Clone, Default, PartialEq)]
pub struct BackendSnapshot { pub struct BackendSnapshot {
pub revision: u64, pub revision: u64,
@@ -234,6 +331,8 @@ pub struct BackendSnapshot {
pub build_info: BuildInfoSnapshot, pub build_info: BuildInfoSnapshot,
pub connected_devices: ConnectedDevicesSnapshot, pub connected_devices: ConnectedDevicesSnapshot,
pub settings: SettingsSnapshot, pub settings: SettingsSnapshot,
pub similarity_status: SimilarityStatusSnapshot,
pub similarity_search: SimilaritySearchSnapshot,
pub playback_error: Option<String>, pub playback_error: Option<String>,
pub settings_error: Option<String>, pub settings_error: Option<String>,
} }
@@ -251,6 +350,8 @@ pub enum BackendCommand {
SetVolume { SetVolume {
volume: f32, volume: f32,
}, },
ToggleShuffle,
CycleRepeat,
PlayRelease { PlayRelease {
release_id: ReleaseKey, release_id: ReleaseKey,
start: usize, start: usize,
@@ -261,6 +362,17 @@ pub enum BackendCommand {
PlayQueueItem { PlayQueueItem {
item_id: QueueItemId, item_id: QueueItemId,
}, },
SearchSimilar {
track: TrackKey,
},
ClearSimilarity,
MoveQueueItem {
item_id: QueueItemId,
target_index: usize,
},
RemoveQueueItem {
item_id: QueueItemId,
},
PlayContext { PlayContext {
tracks: Vec<TrackKey>, tracks: Vec<TrackKey>,
selected: TrackKey, selected: TrackKey,
+6
View File
@@ -7,6 +7,8 @@ license.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
blake3.workspace = true
futures-util.workspace = true
furumi-backend-api.workspace = true furumi-backend-api.workspace = true
furumi-domain.workspace = true furumi-domain.workspace = true
furumi-library.workspace = true furumi-library.workspace = true
@@ -14,6 +16,10 @@ music-dht.workspace = true
directories.workspace = true directories.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
rodio.workspace = true rodio.workspace = true
reqwest.workspace = true
rustfft.workspace = true
sha2.workspace = true
tract-onnx.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-util.workspace = true tokio-util.workspace = true
serde_json.workspace = true serde_json.workspace = true
+58 -7
View File
@@ -216,6 +216,7 @@ impl Actor {
} }
urgent_targets.push(previous); urgent_targets.push(previous);
} }
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.audio.stop(); self.audio.stop();
self.device_role = DevicePlaybackRole::Control; self.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&target.id); self.active_device_id.clone_from(&target.id);
@@ -265,8 +266,8 @@ impl Actor {
.then_some(unix_time_ms()), .then_some(unix_time_ms()),
position_secs: self.state.playback.position_seconds, position_secs: self.state.playback.position_seconds,
volume: volume_percent(self.state.playback.volume), volume: volume_percent(self.state.playback.volume),
shuffle: false, shuffle: self.state.playback.shuffle,
repeat: music_dht::device_sync::PlaybackRepeat::Off, repeat: repeat_to_wire(self.state.playback.repeat),
} }
} }
@@ -311,16 +312,39 @@ impl Actor {
start_audio: bool, start_audio: bool,
seek: bool, seek: bool,
) { ) {
let previous_status = self.state.playback.status;
let previous_index = self.state.queue.current_index();
let previous_track = self
.state
.queue
.current()
.map(|item| item.track.key.clone());
let tracks = wire let tracks = wire
.queue .queue
.iter() .iter()
.filter_map(|track| self.resolve_playback_track(track)) .filter_map(|track| self.resolve_playback_track(track))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !tracks.is_empty() { if !tracks.is_empty() {
self.state.queue.replace_context(tracks, wire.queue_pos); if wire.shuffle {
if !self.state.playback.shuffle {
self.state.queue.remember_order();
}
self.state
.queue
.replace_shuffled_context(tracks, wire.queue_pos);
} else {
self.state.queue.replace_context(tracks, wire.queue_pos);
}
self.resolve_queue_artwork(); self.resolve_queue_artwork();
} }
let current_changed = previous_index != self.state.queue.current_index()
|| previous_track
.as_ref()
.zip(self.state.queue.current().map(|item| &item.track.key))
.is_none_or(|(previous, current)| !previous.matches(current));
self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0; self.state.playback.volume = f32::from(wire.volume.min(100)) / 100.0;
self.state.playback.shuffle = wire.shuffle;
self.state.playback.repeat = repeat_from_wire(wire.repeat);
self.state.playback.position_seconds = wire.position_secs.max(0.0); self.state.playback.position_seconds = wire.position_secs.max(0.0);
self.state.playback.duration_seconds = self self.state.playback.duration_seconds = self
.state .state
@@ -338,9 +362,11 @@ impl Actor {
if !start_audio { if !start_audio {
return; return;
} }
if wire.playing { if !wire.playing {
self.audio.stop();
} else if previous_status == PlaybackStatus::Stopped || current_changed {
self.play_current(); self.play_current();
if seek || wire.position_secs > 0.0 { if seek {
self.audio self.audio
.seek(Duration::from_secs_f64(wire.position_secs.max(0.0))); .seek(Duration::from_secs_f64(wire.position_secs.max(0.0)));
self.state.playback.position_seconds = wire.position_secs.max(0.0); self.state.playback.position_seconds = wire.position_secs.max(0.0);
@@ -349,8 +375,13 @@ impl Actor {
self.audio.pause(); self.audio.pause();
self.state.playback.status = PlaybackStatus::Paused; self.state.playback.status = PlaybackStatus::Paused;
} }
} else { } else if wire.paused {
self.audio.stop(); self.audio.pause();
} else if previous_status == PlaybackStatus::Paused {
self.audio.resume();
} else if seek {
self.audio
.seek(Duration::from_secs_f64(wire.position_secs.max(0.0)));
} }
} }
@@ -479,3 +510,23 @@ impl Actor {
}) })
} }
} }
fn repeat_to_wire(
repeat: furumi_backend_api::PlaybackRepeat,
) -> music_dht::device_sync::PlaybackRepeat {
match repeat {
furumi_backend_api::PlaybackRepeat::Off => music_dht::device_sync::PlaybackRepeat::Off,
furumi_backend_api::PlaybackRepeat::One => music_dht::device_sync::PlaybackRepeat::One,
furumi_backend_api::PlaybackRepeat::All => music_dht::device_sync::PlaybackRepeat::All,
}
}
fn repeat_from_wire(
repeat: music_dht::device_sync::PlaybackRepeat,
) -> furumi_backend_api::PlaybackRepeat {
match repeat {
music_dht::device_sync::PlaybackRepeat::Off => furumi_backend_api::PlaybackRepeat::Off,
music_dht::device_sync::PlaybackRepeat::One => furumi_backend_api::PlaybackRepeat::One,
music_dht::device_sync::PlaybackRepeat::All => furumi_backend_api::PlaybackRepeat::All,
}
}
+253 -4
View File
@@ -12,10 +12,11 @@ use std::time::Duration;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use music_dht::device_sync::{ use music_dht::device_sync::{
DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire, DEFAULT_INVITE_TTL_MS, DEVICE_SYNC_PROTOCOL_VERSION, DeviceProfileWire, InviteWire,
PlaybackCommand, PlaybackSnapshot, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, ListenEndReason, ListenEvent, ListenTrackMetadata, PlaybackCommand, PlaybackSnapshot,
SnapshotPlaylistItem, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, SnapshotLike, SnapshotLikeTombstone, SnapshotPlaylist, SnapshotPlaylistItem,
SyncOpWire, SyncSnapshot, SyncedFedTrack, WireMessage, encode_invite, finish_response, SnapshotPlaylistItemTombstone, SnapshotPlaylistTombstone, SyncOpPayload, SyncOpWire,
finish_send, hash_secret, parse_invite, random_hex, read_msg, ticket_endpoint_id, write_msg, 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 music_dht::{ByteStream, MusicDhtService, PeerTicket, StreamAcceptor};
use rusqlite::{Connection, OptionalExtension as _, params}; use rusqlite::{Connection, OptionalExtension as _, params};
@@ -120,6 +121,10 @@ impl DeviceSync {
Ok((identity.device_id, identity.name)) 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<()> { pub fn set_device_name(&self, name: &str, endpoint_ticket: Option<&str>) -> Result<()> {
let name = if name.trim().is_empty() { let name = if name.trim().is_empty() {
"furumi".to_string() "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<()> { pub fn record_playlist_created(&self, id: i64, title: &str) -> Result<()> {
let playlist_id = self.library.ensure_playlist_sync_id(id)?; let playlist_id = self.library.ensure_playlist_sync_id(id)?;
self.record_op(SyncOpPayload::PlaylistCreated { self.record_op(SyncOpPayload::PlaylistCreated {
@@ -698,6 +753,8 @@ async fn handle_pair_request(
finish_response(&mut stream, RESPONSE_DRAIN).await?; finish_response(&mut stream, RESPONSE_DRAIN).await?;
return Ok(()); 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)?; let devices_json = serde_json::to_string(&requester_devices)?;
lock(&sync.conn).execute( lock(&sync.conn).execute(
"INSERT OR IGNORE INTO sync_pending_pairing "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]) 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( fn valid_pair_request(
sync: &DeviceSync, sync: &DeviceSync,
invite_id: &str, invite_id: &str,
@@ -1115,6 +1181,150 @@ enum PairAttempt {
mod tests { mod tests {
use super::*; 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] #[test]
fn pairing_request_ids_are_stable_and_scoped_to_the_device() { fn pairing_request_ids_are_stable_and_scoped_to_the_device() {
assert_eq!( assert_eq!(
@@ -1126,4 +1336,43 @@ mod tests {
pair_request_id("invite", "device-b") 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"));
});
}
} }
+21 -5
View File
@@ -108,6 +108,7 @@ impl DeviceSync {
.collect::<rusqlite::Result<Vec<_>>>()? .collect::<rusqlite::Result<Vec<_>>>()?
}; };
let pending = { let pending = {
let local_group_id = identity.group_id.as_str();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT request_id, device_id, name, client_version, "SELECT request_id, device_id, name, client_version,
requester_group_id, requester_group_active_devices requester_group_id, requester_group_active_devices
@@ -115,14 +116,25 @@ impl DeviceSync {
ORDER BY created_at_ms", ORDER BY created_at_ms",
)?; )?;
stmt.query_map([], |row| { 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 { Ok(PendingPairing {
request_id: row.get(0)?, request_id: row.get(0)?,
device_id: row.get(1)?, device_id: row.get(1)?,
name: row.get(2)?, name: row.get(2)?,
client_version: row.get(3)?, client_version: row.get(3)?,
requester_group_id: row.get(4)?, requester_group_id: group_conflict.then_some(requester_group_id).flatten(),
requester_group_active_devices: usize::try_from(row.get::<_, i64>(5)?.max(0)) requester_group_active_devices: if group_conflict {
.unwrap_or(usize::MAX), requester_group_active_devices
} else {
0
},
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()? .collect::<rusqlite::Result<Vec<_>>>()?
@@ -480,8 +492,12 @@ impl DeviceSync {
command, command,
} => self.apply_playback_command(target_device_id, command, &op.op_id)?, } => self.apply_playback_command(target_device_id, command, &op.op_id)?,
SyncOpPayload::ListenRecorded { event } => { SyncOpPayload::ListenRecorded { event } => {
self.library if self
.apply_listen_event(event, &op.origin_device_id)?; .library
.apply_listen_event(event, &op.origin_device_id)?
{
self.notify_library();
}
} }
} }
Ok(()) Ok(())
+71 -5
View File
@@ -14,6 +14,8 @@ use furumi_domain::{
use music_dht::catalog::{ use music_dht::catalog::{
CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse, CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse,
}; };
use music_dht::similarity_dht::SimilarityDht;
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
use music_dht::{ use music_dht::{
EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId,
RendezvousConfig, RendezvousConfig,
@@ -82,29 +84,60 @@ const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
pub struct Client { pub struct Client {
service: Arc<MusicDhtService>, service: Arc<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
media_dir: PathBuf, media_dir: PathBuf,
} }
impl Client { 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(&data_dir).await?;
tokio::fs::create_dir_all(&media_dir).await?; tokio::fs::create_dir_all(&media_dir).await?;
let similarity_routing_path = data_dir.join("similarity-routing.sqlite3");
let config = MusicDhtConfig::builder() let config = MusicDhtConfig::builder()
.data_dir(data_dir) .data_dir(&data_dir)
.network_id(NetworkId::from_name(network)) .network_id(NetworkId::from_name(network))
.rendezvous(RendezvousConfig::default()) .rendezvous(RendezvousConfig::default())
.stream_protocol(CATALOG_ALPN) .stream_protocol(CATALOG_ALPN)
.stream_protocol(AUDIO_ALPN) .stream_protocol(AUDIO_ALPN)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V1) .stream_protocol(music_dht::device_sync::SYNC_ALPN_V1)
.stream_protocol(music_dht::device_sync::SYNC_ALPN_V2) .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() .build()
.context("invalid federation configuration")?; .context("invalid federation configuration")?;
let (service, mut events) = MusicDhtService::start(config) let (service, mut events) = MusicDhtService::start(config)
.await .await
.context("starting federation node")?; .context("starting federation node")?;
tokio::spawn(async move { while events.recv().await.is_some() {} }); 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 { Ok(Arc::new(Self {
service: Arc::new(service), service,
similarity_dht,
media_dir, media_dir,
})) }))
} }
@@ -171,6 +204,33 @@ impl Client {
Ok((results, stats)) 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 /// Resolves a portable queue entry when another connected device only
/// knows its stable audio content id. /// knows its stable audio content id.
pub async fn track_by_content_id(&self, content_id: &str) -> Result<Track> { pub async fn track_by_content_id(&self, content_id: &str) -> Result<Track> {
@@ -181,11 +241,17 @@ impl Client {
.into_iter() .into_iter()
.chain(outcome.network_results) .chain(outcome.network_results)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
convert_items(&items, own) let mut track = convert_items(&items, own)
.tracks .tracks
.into_iter() .into_iter()
.next() .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<()> { pub async fn publish(&self, specs: Vec<ItemSpec>) -> Result<()> {
+378
View File
@@ -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
}
+454 -21
View File
@@ -11,8 +11,9 @@ use furumi_backend_api::{
BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot, BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot,
ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust, ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust,
FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot, FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot,
PendingPairingSnapshot, PlaybackStatus, PlaylistSnapshot, RemoteData, RequestId, SearchResults, PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData,
SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, VersionEntrySnapshot, RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot,
SimilarityStatusSnapshot, VersionEntrySnapshot,
}; };
use furumi_domain::{ use furumi_domain::{
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId,
@@ -25,18 +26,20 @@ mod actor_devices;
mod audio; mod audio;
mod devices; mod devices;
mod federation; mod federation;
mod federation_similarity;
mod settings; mod settings;
mod similarity;
mod streaming; mod streaming;
mod support; mod support;
use support::{ use support::{
apply_federated_metadata, expand_tilde, extrapolated_control_position, federation_specs, apply_federated_metadata, extrapolated_control_position, federated_audio_directory,
find_catalog_track, library_snapshot, library_track, local_search_results, federation_specs, find_catalog_track, library_snapshot, library_track, local_search_results,
merge_release_preserving_local, merge_search_results, normalize_device_name, merge_release_preserving_local, merge_search_results, normalize_device_name,
playback_state_acknowledges_command, portable_playback_placeholder, playback_state_acknowledges_command, portable_playback_placeholder,
remote_snapshot_has_authority, runtime_build_info, sanitize_filename, selected_track_position, remote_snapshot_has_authority, runtime_build_info, sanitize_filename, seconds_to_milliseconds,
spawn_settings_worker, track_is_liked, track_to_library_fed, track_to_playback_track, selected_track_position, spawn_settings_worker, track_is_liked, track_to_library_fed,
track_to_synced_fed, unix_time_ms, volume_percent, track_to_playback_track, track_to_synced_fed, unix_time_ms, volume_percent,
}; };
use settings::SettingsStore; use settings::SettingsStore;
@@ -45,6 +48,14 @@ const COMMAND_CAPACITY: usize = 64;
const INTERNAL_CAPACITY: usize = 32; const INTERNAL_CAPACITY: usize = 32;
const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12); const CONTROL_COMMAND_ACK_TIMEOUT: Duration = Duration::from_secs(12);
const CONTROL_POSITION_ACK_TOLERANCE_SECONDS: f64 = 3.0; 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)] #[derive(Clone)]
pub struct BackendHandle { pub struct BackendHandle {
@@ -80,20 +91,26 @@ impl BackendHandle {
/// ///
/// Returns an I/O error when the runtime or its owner thread cannot be created. /// Returns an I/O error when the runtime or its owner thread cannot be created.
pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> { 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)?; .ok_or(BackendStartupError::DataDirectoryUnavailable)?;
let settings_path = project_dirs.data_local_dir().join("furumi-desktop.sqlite3"); 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_data_dir = project_dirs.data_local_dir().join("federation");
let federation_media_dir = project_dirs.cache_dir().join("federation-media"); 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 let devices_db_path = project_dirs
.data_local_dir() .data_local_dir()
.join("devices") .join("devices")
.join("sync.sqlite3"); .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 loaded_settings = settings_store.load()?;
let library_path = furumi_library::default_db_path().map_err(BackendStartupError::Library)?;
let catalog = std::sync::Arc::new( 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 loaded_library = library_snapshot(&catalog).map_err(BackendStartupError::Library)?;
let runtime = tokio::runtime::Builder::new_multi_thread() let runtime = tokio::runtime::Builder::new_multi_thread()
@@ -131,6 +148,7 @@ pub fn spawn_backend() -> Result<BackendHandle, BackendStartupError> {
audio, audio,
federation_data_dir, federation_data_dir,
federation_media_dir, federation_media_dir,
similarity_model_dir,
devices, devices,
})); }));
})?; })?;
@@ -217,6 +235,10 @@ enum InternalEvent {
keys: Vec<TrackKey>, keys: Vec<TrackKey>,
cover_uri: Option<String>, cover_uri: Option<String>,
}, },
HistoryTrackResolved {
content_id: String,
result: Result<Track, String>,
},
FederationDebugUpdated(FederationDebugSnapshot), FederationDebugUpdated(FederationDebugSnapshot),
DevicesChanged, DevicesChanged,
DeviceLibraryChanged, DeviceLibraryChanged,
@@ -225,6 +247,12 @@ enum InternalEvent {
DeviceOperationFinished(Result<DeviceOperationResult, String>), DeviceOperationFinished(Result<DeviceOperationResult, String>),
DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot), DevicePlaybackSnapshot(music_dht::device_sync::PlaybackSnapshot),
DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand), DevicePlaybackCommand(music_dht::device_sync::PlaybackCommand),
SimilarityStatus(similarity::SimilarityStatus),
SimilarityProfileActivated(Option<String>),
SimilaritySearchFinished {
source_title: String,
result: Result<Vec<Track>, String>,
},
} }
enum DeviceOperationResult { enum DeviceOperationResult {
@@ -245,8 +273,10 @@ struct Actor {
federation: Option<std::sync::Arc<federation::Client>>, federation: Option<std::sync::Arc<federation::Client>>,
federation_data_dir: std::path::PathBuf, federation_data_dir: std::path::PathBuf,
federation_media_dir: std::path::PathBuf, federation_media_dir: std::path::PathBuf,
similarity: std::sync::Arc<similarity::Manager>,
ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>, ephemeral_audio: Option<(TrackKey, std::path::PathBuf)>,
pending_queue_artwork: HashSet<String>, pending_queue_artwork: HashSet<String>,
pending_history_resolutions: HashSet<String>,
federation_debug_pending: bool, federation_debug_pending: bool,
devices: std::sync::Arc<devices::DeviceSync>, devices: std::sync::Arc<devices::DeviceSync>,
device_service: Option<std::sync::Arc<music_dht::MusicDhtService>>, device_service: Option<std::sync::Arc<music_dht::MusicDhtService>>,
@@ -255,6 +285,14 @@ struct Actor {
active_device_name: String, active_device_name: String,
control_anchor: Option<ControlPlaybackAnchor>, control_anchor: Option<ControlPlaybackAnchor>,
pending_control: Option<PendingControlState>, pending_control: Option<PendingControlState>,
listen_session: Option<ListenSession>,
}
#[derive(Debug, Clone)]
struct ListenSession {
id: String,
track: Track,
started_at_ms: i64,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -284,6 +322,7 @@ struct ActorBootstrap {
audio: audio::Controller, audio: audio::Controller,
federation_data_dir: std::path::PathBuf, federation_data_dir: std::path::PathBuf,
federation_media_dir: std::path::PathBuf, federation_media_dir: std::path::PathBuf,
similarity_model_dir: std::path::PathBuf,
devices: std::sync::Arc<devices::DeviceSync>, devices: std::sync::Arc<devices::DeviceSync>,
} }
@@ -306,6 +345,7 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
audio, audio,
federation_data_dir, federation_data_dir,
federation_media_dir, federation_media_dir,
similarity_model_dir,
devices, devices,
} = bootstrap; } = bootstrap;
let (settings_tx, settings_rx) = std_mpsc::channel(); let (settings_tx, settings_rx) = std_mpsc::channel();
@@ -335,6 +375,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
settings_error, settings_error,
..BackendSnapshot::default() ..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 { let mut actor = Actor {
state: initial_state, state: initial_state,
library, library,
@@ -348,8 +394,10 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
federation: None, federation: None,
federation_data_dir, federation_data_dir,
federation_media_dir, federation_media_dir,
similarity,
ephemeral_audio: None, ephemeral_audio: None,
pending_queue_artwork: HashSet::new(), pending_queue_artwork: HashSet::new(),
pending_history_resolutions: HashSet::new(),
federation_debug_pending: false, federation_debug_pending: false,
devices, devices,
device_service: None, device_service: None,
@@ -358,7 +406,12 @@ fn initialize_actor(bootstrap: ActorBootstrap) -> ActorRuntime {
active_device_name, active_device_name,
control_anchor: None, control_anchor: None,
pending_control: 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(); actor.refresh_connected_devices();
ActorRuntime { ActorRuntime {
actor, actor,
@@ -367,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) { async fn run_actor(bootstrap: ActorBootstrap) {
let ActorRuntime { let ActorRuntime {
mut actor, mut actor,
@@ -399,6 +468,7 @@ async fn run_actor(bootstrap: ActorBootstrap) {
if let Some((_, token)) = actor.active_search.take() { if let Some((_, token)) = actor.active_search.take() {
token.cancel(); token.cancel();
} }
actor.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
actor.audio.stop(); actor.audio.stop();
} }
@@ -415,6 +485,7 @@ impl Actor {
let data_dir = self.federation_data_dir.clone(); let data_dir = self.federation_data_dir.clone();
let media_dir = self.federation_media_dir.clone(); let media_dir = self.federation_media_dir.clone();
let network = self.state.settings.network_id.trim().to_owned(); let network = self.state.settings.network_id.trim().to_owned();
let similarity = std::sync::Arc::clone(&self.similarity);
let internal = self.internal.clone(); let internal = self.internal.clone();
self.state.federation_activity = FederationActivitySnapshot { self.state.federation_activity = FederationActivitySnapshot {
operation: FederationOperation::Idle, operation: FederationOperation::Idle,
@@ -424,7 +495,7 @@ impl Actor {
}; };
self.publish(); self.publish();
tokio::spawn(async move { 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 .await
.map_err(|error| format!("federation: {error:#}")); .map_err(|error| format!("federation: {error:#}"));
let _ = internal let _ = internal
@@ -658,6 +729,7 @@ impl Actor {
} }
} }
BackendCommand::Stop => { BackendCommand::Stop => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
if self.device_role == DevicePlaybackRole::Active { if self.device_role == DevicePlaybackRole::Active {
self.audio.stop(); self.audio.stop();
} }
@@ -688,9 +760,25 @@ impl Actor {
} }
self.publish(); self.publish();
} }
BackendCommand::ToggleShuffle => {
self.state.playback.shuffle = !self.state.playback.shuffle;
if self.state.playback.shuffle {
self.state.queue.shuffle_upcoming();
} else {
self.state.queue.restore_upcoming_order();
}
self.send_control_state(false);
self.publish();
}
BackendCommand::CycleRepeat => {
self.state.playback.repeat = self.state.playback.repeat.next();
self.send_control_state(false);
self.publish();
}
BackendCommand::PlayRelease { release_id, start } => { BackendCommand::PlayRelease { release_id, start } => {
if let Some(release) = self.release(&release_id).cloned() { if let Some(release) = self.release(&release_id).cloned() {
self.state.queue.replace_context(release.tracks, start); self.state.queue.replace_context(release.tracks, start);
self.shuffle_new_context();
self.resolve_queue_artwork(); self.resolve_queue_artwork();
self.play_current(); self.play_current();
} }
@@ -698,6 +786,7 @@ impl Actor {
BackendCommand::PlayTrack { track } => { BackendCommand::PlayTrack { track } => {
if let Some(track) = self.track(&track).cloned() { if let Some(track) = self.track(&track).cloned() {
self.state.queue.replace_context(vec![track], 0); self.state.queue.replace_context(vec![track], 0);
self.shuffle_new_context();
self.resolve_queue_artwork(); self.resolve_queue_artwork();
self.play_current(); self.play_current();
} }
@@ -707,11 +796,42 @@ impl Actor {
self.play_current(); 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 } => { BackendCommand::PlayContext { tracks, selected } => {
let tracks = self.resolve_tracks(&tracks); let tracks = self.resolve_tracks(&tracks);
if !tracks.is_empty() { if !tracks.is_empty() {
let start = selected_track_position(&tracks, &selected); let start = selected_track_position(&tracks, &selected);
self.state.queue.replace_context(tracks, start); self.state.queue.replace_context(tracks, start);
self.shuffle_new_context();
self.resolve_queue_artwork(); self.resolve_queue_artwork();
self.play_current(); self.play_current();
} }
@@ -808,7 +928,7 @@ impl Actor {
self.select_playback_device(&device_id); self.select_playback_device(&device_id);
} }
BackendCommand::Next => { BackendCommand::Next => {
if self.state.queue.advance().is_some() { if self.advance_queue(false) {
self.play_current(); self.play_current();
} }
} }
@@ -826,6 +946,8 @@ impl Actor {
} }
} }
BackendCommand::Search { request_id, query } => self.start_search(request_id, query), 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::CancelSearch { request_id } => self.cancel_search(request_id),
BackendCommand::LoadArtist { BackendCommand::LoadArtist {
request_id, request_id,
@@ -838,11 +960,13 @@ impl Actor {
artist_key, artist_key,
artist_name, artist_name,
} => self.load_detail(request_id, artist_key, artist_name, Some(key)), } => 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 let federation_changed = self.state.settings.federation_enabled
!= settings.federation_enabled != settings.federation_enabled
|| self.state.settings.network_id != settings.network_id; || self.state.settings.network_id != settings.network_id;
let device_name_changed = self.state.settings.device_name != settings.device_name; 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(); let pending_device_name = settings.device_name.clone();
self.state.settings = settings.clone(); self.state.settings = settings.clone();
self.state.settings_error = None; self.state.settings_error = None;
@@ -859,6 +983,9 @@ impl Actor {
self.apply_local_device_name(&pending_device_name); self.apply_local_device_name(&pending_device_name);
self.schedule_device_name_publish(pending_device_name); self.schedule_device_name_publish(pending_device_name);
} }
if similarity_changed {
self.similarity.apply(&self.state.settings.similarity);
}
} }
BackendCommand::Shutdown => {} BackendCommand::Shutdown => {}
} }
@@ -1128,6 +1255,7 @@ impl Actor {
}); });
} }
self.federation = Some(client); self.federation = Some(client);
self.resolve_history_tracks();
self.state.federation_activity.pending = false; self.state.federation_activity.pending = false;
self.resolve_queue_artwork(); self.resolve_queue_artwork();
self.refresh_federation_debug(); self.refresh_federation_debug();
@@ -1204,6 +1332,7 @@ impl Actor {
.current() .current()
.is_some_and(|item| item.track.key.matches(&key)) .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.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message); self.state.playback_error = Some(message);
self.publish(); self.publish();
@@ -1251,6 +1380,7 @@ impl Actor {
} }
} }
Err(message) => { Err(message) => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped; self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message); self.state.playback_error = Some(message);
self.publish(); self.publish();
@@ -1280,6 +1410,27 @@ impl Actor {
self.publish(); 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) => { InternalEvent::FederationDebugUpdated(debug) => {
self.federation_debug_pending = false; self.federation_debug_pending = false;
if self.state.federation_debug != debug { if self.state.federation_debug != debug {
@@ -1292,6 +1443,8 @@ impl Actor {
self.library = library.clone(); self.library = library.clone();
self.state.library = RemoteData::Ready(library); self.state.library = RemoteData::Ready(library);
self.reconcile_likes(); self.reconcile_likes();
self.similarity.start();
self.resolve_history_tracks();
} }
self.refresh_connected_devices(); self.refresh_connected_devices();
self.publish(); self.publish();
@@ -1319,9 +1472,129 @@ impl Actor {
InternalEvent::DevicePlaybackCommand(command) => { InternalEvent::DevicePlaybackCommand(command) => {
self.apply_device_playback_command(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( fn apply_remote_playback_snapshot(
&mut self, &mut self,
snapshot: music_dht::device_sync::PlaybackSnapshot, snapshot: music_dht::device_sync::PlaybackSnapshot,
@@ -1359,6 +1632,9 @@ impl Actor {
self.pending_control = None; 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.device_role = DevicePlaybackRole::Control;
self.active_device_id.clone_from(&snapshot.device_id); self.active_device_id.clone_from(&snapshot.device_id);
self.active_device_name.clone_from(&snapshot.device_name); self.active_device_name.clone_from(&snapshot.device_name);
@@ -1426,8 +1702,9 @@ impl Actor {
self.publish(); self.publish();
} }
audio::Event::Finished => { audio::Event::Finished => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Finished);
self.remove_ephemeral_audio(); self.remove_ephemeral_audio();
if self.state.queue.advance().is_some() { if self.advance_queue(true) {
self.play_current(); self.play_current();
} else { } else {
self.state.playback.status = PlaybackStatus::Stopped; self.state.playback.status = PlaybackStatus::Stopped;
@@ -1437,6 +1714,7 @@ impl Actor {
} }
} }
audio::Event::Failed(message) => { audio::Event::Failed(message) => {
self.finish_listen(music_dht::device_sync::ListenEndReason::Stopped);
self.state.playback.status = PlaybackStatus::Stopped; self.state.playback.status = PlaybackStatus::Stopped;
self.state.playback_error = Some(message); self.state.playback_error = Some(message);
self.publish(); self.publish();
@@ -1447,10 +1725,12 @@ impl Actor {
fn play_current(&mut self) { fn play_current(&mut self) {
self.remove_ephemeral_audio(); self.remove_ephemeral_audio();
let Some(track) = self.state.queue.current().map(|item| item.track.clone()) else { 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.state.playback.status = PlaybackStatus::Stopped;
self.publish(); self.publish();
return; return;
}; };
self.begin_listen(&track);
self.state.playback.position_seconds = 0.0; self.state.playback.position_seconds = 0.0;
self.state.playback.duration_seconds = track.duration_seconds.max(0.0); self.state.playback.duration_seconds = track.duration_seconds.max(0.0);
self.state.playback_error = None; self.state.playback_error = None;
@@ -1476,8 +1756,67 @@ impl Actor {
self.publish(); 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();
}
}
fn advance_queue(&mut self, natural_finish: bool) -> bool {
if natural_finish && self.state.playback.repeat == PlaybackRepeat::One {
return self.state.queue.current().is_some();
}
if self.state.queue.advance().is_some() {
return true;
}
self.state.playback.repeat == PlaybackRepeat::All
&& self.state.queue.select_index(0).is_some()
}
fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) { fn start_federated_playback(&mut self, track: Track, peer_id: String, content_id: ContentId) {
let Some(client) = self.federation.clone() else { 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.state.playback_error = Some("federation is still starting".into());
self.publish(); self.publish();
return; return;
@@ -1512,11 +1851,11 @@ impl Actor {
let key = track.key.clone(); let key = track.key.clone();
let internal = self.internal.clone(); let internal = self.internal.clone();
let keep = self.state.settings.save_federated_on_listen; let keep = self.state.settings.save_federated_on_listen;
let directory = if keep { let directory = federated_audio_directory(
expand_tilde(&self.state.settings.library_path) &self.state.settings.library_path,
} else { keep,
self.federation_media_dir.join("stream-cache") &self.federation_media_dir,
}; );
let stem = format!("fed-{}", sanitize_filename(&track.title)); let stem = format!("fed-{}", sanitize_filename(&track.title));
tokio::spawn(async move { tokio::spawn(async move {
let (tx, mut rx) = mpsc::channel(4); let (tx, mut rx) = mpsc::channel(4);
@@ -1637,7 +1976,20 @@ impl Actor {
} }
fn track(&self, key: &TrackKey) -> Option<&Track> { 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> { fn resolve_tracks(&self, keys: &[TrackKey]) -> Vec<Track> {
@@ -1652,6 +2004,7 @@ impl Actor {
self.library = library.clone(); self.library = library.clone();
self.state.library = RemoteData::Ready(library); self.state.library = RemoteData::Ready(library);
self.reconcile_likes(); self.reconcile_likes();
self.resolve_history_tracks();
} }
Err(error) => { Err(error) => {
self.state.settings_error = Some(format!("library: {error:#}")); self.state.settings_error = Some(format!("library: {error:#}"));
@@ -1660,6 +2013,35 @@ impl Actor {
self.publish(); 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) { fn reconcile_likes(&mut self) {
let liked = self let liked = self
.catalog .catalog
@@ -1678,11 +2060,15 @@ impl Actor {
) { ) {
track.liked = track_is_liked(track, &liked); 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 let replacements = self
.library .library
.featured_releases .featured_releases
.iter() .iter()
.flat_map(|release| release.tracks.iter()) .flat_map(|release| release.tracks.iter())
.chain(self.library.recently_played.iter())
.chain( .chain(
self.library self.library
.playlists .playlists
@@ -1812,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)] #[cfg(test)]
mod tests; mod tests;
+136 -8
View File
@@ -4,6 +4,9 @@ use std::path::Path;
use furumi_backend_api::SettingsSnapshot; use furumi_backend_api::SettingsSnapshot;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
const LEGACY_DEFAULT_LIBRARY_PATH: &str = "~/Music/Furumi";
const PLATFORM_LIBRARY_PATH_MIGRATION: i64 = 4;
const MIGRATIONS: &[(i64, &str)] = &[ const MIGRATIONS: &[(i64, &str)] = &[
( (
1, 1,
@@ -28,6 +31,19 @@ const MIGRATIONS: &[(i64, &str)] = &[
3, 3,
"ALTER TABLE app_settings ADD COLUMN device_name TEXT NOT NULL DEFAULT '';", "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 { pub struct SettingsStore {
@@ -35,32 +51,47 @@ pub struct SettingsStore {
} }
impl 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() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?; .map_err(|error| rusqlite::Error::ToSqlConversionFailure(Box::new(error)))?;
} }
let connection = Connection::open(path)?; let connection = Connection::open(path)?;
let mut store = Self { connection }; let mut store = Self { connection };
store.migrate()?; store.migrate(default_library_path)?;
Ok(store) Ok(store)
} }
#[cfg(test)] #[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 connection = Connection::open_in_memory()?;
let mut store = Self { connection }; let mut store = Self { connection };
store.migrate()?; store.migrate(default_library_path)?;
Ok(store) Ok(store)
} }
pub fn load(&self) -> rusqlite::Result<SettingsSnapshot> { pub fn load(&self) -> rusqlite::Result<SettingsSnapshot> {
self.connection.query_row( self.connection.query_row(
"SELECT network_id, library_path, federation_enabled, language, "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", FROM app_settings WHERE singleton_id = 1",
[], [],
|row| { |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 { Ok(SettingsSnapshot {
network_id: row.get(0)?, network_id: row.get(0)?,
library_path: row.get(1)?, library_path: row.get(1)?,
@@ -68,6 +99,7 @@ impl SettingsStore {
language: row.get(3)?, language: row.get(3)?,
save_federated_on_listen: row.get::<_, i64>(4)? != 0, save_federated_on_listen: row.get::<_, i64>(4)? != 0,
device_name: row.get(5)?, device_name: row.get(5)?,
similarity,
}) })
}, },
) )
@@ -81,7 +113,15 @@ impl SettingsStore {
federation_enabled = ?3, federation_enabled = ?3,
language = ?4, language = ?4,
save_federated_on_listen = ?5, 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", WHERE singleton_id = 1",
params![ params![
settings.network_id, settings.network_id,
@@ -90,12 +130,20 @@ impl SettingsStore {
settings.language, settings.language,
i64::from(settings.save_federated_on_listen), i64::from(settings.save_federated_on_listen),
settings.device_name, 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(()) Ok(())
} }
fn migrate(&mut self) -> rusqlite::Result<()> { fn migrate(&mut self, default_library_path: &Path) -> rusqlite::Result<()> {
self.connection.execute_batch( self.connection.execute_batch(
"PRAGMA foreign_keys = ON; "PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -119,14 +167,55 @@ impl SettingsStore {
} }
let transaction = self.connection.transaction()?; let transaction = self.connection.transaction()?;
transaction.execute_batch(sql)?; 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( transaction.execute(
"INSERT INTO schema_migrations (version) VALUES (?1)", "INSERT INTO schema_migrations (version) VALUES (?1)",
[version], [version],
)?; )?;
transaction.commit()?; transaction.commit()?;
} }
self.migrate_platform_library_path(default_library_path)?;
Ok(()) 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)] #[cfg(test)]
@@ -135,19 +224,58 @@ mod tests {
#[test] #[test]
fn migration_creates_defaults_and_settings_round_trip() { 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(); let mut settings = store.load().unwrap();
assert_eq!(settings.network_id, "furumi"); assert_eq!(settings.network_id, "furumi");
assert_eq!(
settings.library_path,
default_library_path.to_string_lossy()
);
assert!(settings.federation_enabled); assert!(settings.federation_enabled);
assert!(settings.save_federated_on_listen); assert!(settings.save_federated_on_listen);
assert!(settings.device_name.is_empty()); 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.network_id = "friends".into();
settings.device_name = "Studio Mac".into(); settings.device_name = "Studio Mac".into();
settings.library_path = "/music/library".into(); settings.library_path = "/music/library".into();
settings.federation_enabled = false; 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(); store.save(&settings).unwrap();
assert_eq!(store.load().unwrap(), settings); 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
+77 -5
View File
@@ -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 { pub(super) fn normalize_device_name(value: &str) -> String {
let value = value.trim(); let value = value.trim();
if value.is_empty() { if value.is_empty() {
@@ -31,10 +43,18 @@ pub(super) fn selected_track_position(tracks: &[Track], selected: &TrackKey) ->
.unwrap_or(0) .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 { pub(super) fn runtime_build_info() -> BuildInfoSnapshot {
use music_dht::capabilities::{ use music_dht::capabilities::{
CATALOG_ID, CapabilityManifest, DEVICE_SYNC_ID, FEDERATION_NET_ID, MUSIC_DHT_ID, 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")); 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("Rendezvous", RENDEZVOUS_ID),
protocol("Music DHT", MUSIC_DHT_ID), protocol("Music DHT", MUSIC_DHT_ID),
protocol("Catalog", CATALOG_ID), protocol("Catalog", CATALOG_ID),
protocol("Similarity search", SIMILARITY_ID),
protocol("Similarity routing", SIMILARITY_DHT_ID),
VersionEntrySnapshot { VersionEntrySnapshot {
name: "Audio transfer".into(), name: "Audio transfer".into(),
version: federation::AUDIO_PROTOCOL_VERSION.to_string(), version: federation::AUDIO_PROTOCOL_VERSION.to_string(),
@@ -98,6 +120,7 @@ pub(super) fn find_catalog_track<'a>(
.featured_releases .featured_releases
.iter() .iter()
.flat_map(|release| release.tracks.iter()) .flat_map(|release| release.tracks.iter())
.chain(library.recently_played.iter())
.chain( .chain(
library library
.playlists .playlists
@@ -785,10 +808,22 @@ pub(super) fn library_snapshot(
tracks, tracks,
}); });
} }
let recently_played = releases let recently_played = catalog
.iter() .listen_history(500)?
.flat_map(|release| release.tracks.iter().cloned()) .into_iter()
.take(12) .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(); .collect();
let mut playlists = Vec::new(); let mut playlists = Vec::new();
for card in catalog.playlists()? { 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 { pub(super) fn track_is_liked(track: &Track, liked_ids: &HashSet<String>) -> bool {
track track
.key .key
+65
View File
@@ -1,5 +1,19 @@
use super::*; 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 { fn merge_test_track(key: TrackKey, audio_source: AudioSource) -> Track {
Track { Track {
key, 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] #[test]
fn merging_search_results_deduplicates_source_keys() { fn merging_search_results_deduplicates_source_keys() {
let artist = Artist { let artist = Artist {
+179
View File
@@ -326,6 +326,7 @@ pub struct Queue {
items: Vec<QueueItem>, items: Vec<QueueItem>,
current: Option<usize>, current: Option<usize>,
play_next_end: Option<usize>, play_next_end: Option<usize>,
original_order: Option<Vec<TrackKey>>,
next_item_id: u64, next_item_id: u64,
} }
@@ -360,6 +361,7 @@ impl Queue {
self.items = items; self.items = items;
self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1)); self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1));
self.play_next_end = None; self.play_next_end = None;
self.original_order = None;
} }
pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) { pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) {
@@ -391,6 +393,44 @@ impl Queue {
self.play_next_end = Some(insertion + count); 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) { pub fn replace_matching_track(&mut self, replacement: &Track) {
for item in &mut self.items { for item in &mut self.items {
if item.track.key.matches(&replacement.key) { if item.track.key.matches(&replacement.key) {
@@ -425,6 +465,87 @@ impl Queue {
self.current() self.current()
} }
/// Selects a position directly, preserving the existing queue context.
pub fn select_index(&mut self, index: usize) -> Option<&QueueItem> {
if index >= self.items.len() {
return None;
}
self.current = Some(index);
self.play_next_end = None;
self.current()
}
/// Randomizes only the part of the queue that has not played yet.
///
/// The original order is retained so disabling shuffle can restore it.
pub fn shuffle_upcoming(&mut self) {
let start = self
.current
.map_or(0, |current| (current + 1).min(self.items.len()));
if self.items.len().saturating_sub(start) < 2 {
return;
}
if self.original_order.is_none() {
self.remember_order();
}
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(1, |duration| {
duration.as_secs() ^ u64::from(duration.subsec_nanos())
})
| 1;
let mut random = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed
};
let tail = &mut self.items[start..];
for index in (1..tail.len()).rev() {
let bound = u64::try_from(index + 1).unwrap_or(u64::MAX);
let shuffled = usize::try_from(random() % bound).unwrap_or(0);
tail.swap(index, shuffled);
}
self.play_next_end = None;
}
/// Remembers the current catalog order before another device sends a
/// physically shuffled queue through the connected-devices protocol.
pub fn remember_order(&mut self) {
if self.original_order.is_none() {
self.original_order = Some(
self.items
.iter()
.map(|item| item.track.key.clone())
.collect(),
);
}
}
/// Replaces a synchronized queue without losing its pre-shuffle order.
pub fn replace_shuffled_context(&mut self, tracks: Vec<Track>, start: usize) {
let original_order = self.original_order.take();
self.replace_context(tracks, start);
self.original_order = original_order;
}
/// Restores the unplayed queue tail to its order before shuffle.
pub fn restore_upcoming_order(&mut self) {
let Some(order) = self.original_order.take() else {
return;
};
let start = self
.current
.map_or(0, |current| (current + 1).min(self.items.len()));
self.items[start..].sort_by_key(|item| {
order
.iter()
.position(|original| original.matches(&item.track.key))
.unwrap_or(usize::MAX)
});
self.play_next_end = None;
}
fn new_item(&mut self, track: Track) -> QueueItem { fn new_item(&mut self, track: Track) -> QueueItem {
self.next_item_id = self.next_item_id.saturating_add(1); self.next_item_id = self.next_item_id.saturating_add(1);
QueueItem { QueueItem {
@@ -506,6 +627,64 @@ 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();
queue.replace_context((1..=8).map(track).collect(), 2);
let current = queue.current().unwrap().id;
queue.shuffle_upcoming();
assert_eq!(queue.current().unwrap().id, current);
let mut shuffled_tail: Vec<_> = queue.items()[3..]
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
shuffled_tail.sort_unstable();
assert_eq!(shuffled_tail, vec![4, 5, 6, 7, 8]);
queue.restore_upcoming_order();
let restored: Vec<_> = queue
.items()
.iter()
.map(|item| item.track.key.local_id().unwrap().get())
.collect();
assert_eq!(restored, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test] #[test]
fn catalog_track_matching_falls_back_to_album_position() { fn catalog_track_matching_falls_back_to_album_position() {
let local = track(1); let local = track(1);
+3
View File
@@ -11,6 +11,9 @@ souvlaki.workspace = true
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
core-foundation.workspace = true core-foundation.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-foundation.workspace = true
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys.workspace = true windows-sys.workspace = true
+38
View File
@@ -0,0 +1,38 @@
/// Applies the Furumi federation mark as the native application icon.
///
/// Slint forwards its window icon to Windows and X11. macOS deliberately
/// ignores that window-level API, so its Dock icon is installed through
/// `AppKit` from the same embedded SVG asset. On macOS this must be called
/// from the main event loop after `applicationDidFinishLaunching`.
pub fn set_application_icon() {
if let Err(error) = set_native_application_icon() {
eprintln!("failed to install the Furumi application icon: {error}");
}
}
#[cfg(target_os = "macos")]
fn set_native_application_icon() -> Result<(), &'static str> {
use std::ffi::c_void;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
let main_thread = MainThreadMarker::new().ok_or("not running on the macOS main thread")?;
let svg = include_bytes!("../../ui/assets/federation.svg");
// SAFETY: `dataWithBytes_length` copies `svg`; its pointer is valid for
// the complete duration of the Objective-C call.
let data = unsafe { NSData::dataWithBytes_length(svg.as_ptr().cast::<c_void>(), svg.len()) };
let icon = NSImage::initWithData(main_thread.alloc(), &data)
.ok_or("AppKit could not decode the embedded federation SVG")?;
let application = NSApplication::sharedApplication(main_thread);
// SAFETY: AppKit accepts a live NSImage here and retains it as the
// application's Dock icon. This function is restricted to the main thread.
unsafe { application.setApplicationIconImage(Some(&icon)) };
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn set_native_application_icon() -> Result<(), &'static str> {
Ok(())
}
+2
View File
@@ -2,7 +2,9 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
mod app;
mod media; mod media;
pub use app::set_application_icon;
pub use media::{MediaCommand, MediaSession}; pub use media::{MediaCommand, MediaSession};
/// Opens the platform-native directory picker. /// Opens the platform-native directory picker.
+5
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
<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="M17 2.8 20.2 6 17 9.2"/>
<path d="M3.8 11V9a3 3 0 0 1 3-3h13.4"/>
<path d="M7 21.2 3.8 18 7 14.8"/>
<path d="M20.2 13v2a3 3 0 0 1-3 3H3.8"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+7
View File
@@ -0,0 +1,7 @@
<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 7h3.4c4.8 0 6.5 10 11.2 10H21"/>
<path d="m18 14 3 3-3 3"/>
<path d="M3 17h3.4c1.8 0 3.1-1.4 4.3-3.2"/>
<path d="M13.4 9.4C14.6 8 15.9 7 17.6 7H21"/>
<path d="m18 4 3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

+182 -4
View File
@@ -14,7 +14,7 @@ use furumi_application::{
use furumi_backend::BackendHandle; use furumi_backend::BackendHandle;
use furumi_backend_api::{ use furumi_backend_api::{
BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation, BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation,
PlaybackStatus, RemoteData, PlaybackRepeat, PlaybackStatus, RemoteData,
}; };
use furumi_domain::{ use furumi_domain::{
Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId, Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId,
@@ -29,7 +29,7 @@ slint::include_modules!();
mod render; mod render;
use 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, parse_track_key, release_artist_credits, render, render_catalog, render_current_track,
render_playback, render_queue, render_search, render_shell, render_track_info, render_playback, render_queue, render_search, render_shell, render_track_info,
selected_release, track_context, selected_release, track_context,
@@ -50,6 +50,13 @@ pub fn run(backend: &BackendHandle) -> Result<(), slint::PlatformError> {
let window = AppWindow::new()?; let window = AppWindow::new()?;
let state = Arc::new(Mutex::new(AppState::default())); let state = Arc::new(Mutex::new(AppState::default()));
// Winit completes NSApplication initialization only after entering the
// event loop. AppKit discards a Dock icon assigned before that point.
#[cfg(target_os = "macos")]
slint::Timer::single_shot(std::time::Duration::ZERO, || {
furumi_platform_desktop::set_application_icon();
});
let media_backend = backend.clone(); let media_backend = backend.clone();
MEDIA_SESSION.with_borrow_mut(|session| { MEDIA_SESSION.with_borrow_mut(|session| {
*session = MediaSession::new(move |command| { *session = MediaSession::new(move |command| {
@@ -92,6 +99,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
let screen = match target.as_str() { let screen = match target.as_str() {
"search" => Screen::Search, "search" => Screen::Search,
"library" => Screen::Library, "library" => Screen::Library,
"history" => Screen::History,
_ => Screen::Home, _ => Screen::Home,
}; };
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
@@ -137,6 +145,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
"add-end" => UiAction::AddToEnd(vec![key]), "add-end" => UiAction::AddToEnd(vec![key]),
"information" => UiAction::ShowTrackInfo(key), "information" => UiAction::ShowTrackInfo(key),
"playlist" => UiAction::ShowPlaylistPicker(key), "playlist" => UiAction::ShowPlaylistPicker(key),
"similar" => UiAction::SearchSimilar(key),
// The remaining presentation actions terminate here until // The remaining presentation actions terminate here until
// their backend capabilities are introduced. // their backend capabilities are introduced.
_ => return, _ => return,
@@ -145,6 +154,7 @@ fn bind_callbacks(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &Ba
} }
}); });
bind_playlist_callbacks(window, state, backend); bind_playlist_callbacks(window, state, backend);
bind_queue_callbacks(window, state, backend);
bind_device_callbacks(window, state, backend); bind_device_callbacks(window, state, backend);
bind_settings_callbacks(window, state, backend); bind_settings_callbacks(window, state, backend);
window.on_dismiss_error({ window.on_dismiss_error({
@@ -229,6 +239,9 @@ fn bind_playlist_callbacks(
let backend = backend.clone(); let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ClosePlaylistPicker) 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({ window.on_play_queue_item({
let window = window.as_weak(); let window = window.as_weak();
let state = Arc::clone(state); let state = Arc::clone(state);
@@ -244,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( fn bind_catalog_callbacks(
@@ -288,7 +336,7 @@ fn bind_catalog_callbacks(
let state = Arc::clone(state); let state = Arc::clone(state);
let backend = backend.clone(); let backend = backend.clone();
move |target| { move |target| {
let screen = with_state(&state, |state| breadcrumb_screen(state, &target)); let screen = breadcrumb_screen(&target);
if let Some(screen) = screen { if let Some(screen) = screen {
dispatch_action(&window, &state, &backend, UiAction::Navigate(screen)); dispatch_action(&window, &state, &backend, UiAction::Navigate(screen));
} }
@@ -299,7 +347,7 @@ fn bind_catalog_callbacks(
let state = Arc::clone(state); let state = Arc::clone(state);
let backend = backend.clone(); let backend = backend.clone();
move |key| { move |key| {
let artist = with_state(&state, |state| find_artist_key(state, &key)); let artist = parse_artist_key(&key);
if let Some(key) = artist { if let Some(key) = artist {
dispatch_action( dispatch_action(
&window, &window,
@@ -357,6 +405,18 @@ fn bind_player_callbacks(
let backend = backend.clone(); let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::Previous) move || dispatch_action(&window, &state, &backend, UiAction::Previous)
}); });
window.on_toggle_shuffle({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::ToggleShuffle)
});
window.on_cycle_repeat({
let window = window.as_weak();
let state = Arc::clone(state);
let backend = backend.clone();
move || dispatch_action(&window, &state, &backend, UiAction::CycleRepeat)
});
window.on_seek({ window.on_seek({
let window = window.as_weak(); let window = window.as_weak();
let state = Arc::clone(state); let state = Arc::clone(state);
@@ -500,6 +560,7 @@ fn bind_settings_callbacks(
); );
} }
}); });
bind_similarity_settings_callbacks(window, state, backend);
window.on_language_changed({ window.on_language_changed({
let window = window.as_weak(); let window = window.as_weak();
let state = Arc::clone(state); let state = Arc::clone(state);
@@ -515,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) { fn bind_library_picker(window: &AppWindow, state: &Arc<Mutex<AppState>>, backend: &BackendHandle) {
window.on_choose_library_path({ window.on_choose_library_path({
let window = window.as_weak(); let window = window.as_weak();
@@ -698,11 +871,13 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
|| previous.federation_debug != state.backend.federation_debug || previous.federation_debug != state.backend.federation_debug
|| previous.connected_devices != state.backend.connected_devices || previous.connected_devices != state.backend.connected_devices
|| previous.settings != state.backend.settings || previous.settings != state.backend.settings
|| previous.similarity_status != state.backend.similarity_status
|| previous.playback_error != state.backend.playback_error || previous.playback_error != state.backend.playback_error
|| previous.settings_error != state.backend.settings_error; || previous.settings_error != state.backend.settings_error;
let catalog_changed = previous.library != state.backend.library let catalog_changed = previous.library != state.backend.library
|| previous.search != state.backend.search || previous.search != state.backend.search
|| previous.queue != state.backend.queue; || previous.queue != state.backend.queue;
let similarity_changed = previous.similarity_search != state.backend.similarity_search;
let queue_changed = previous.queue != state.backend.queue; let queue_changed = previous.queue != state.backend.queue;
if shell_changed { if shell_changed {
render_shell(window, state); render_shell(window, state);
@@ -716,6 +891,9 @@ fn dispatch_event(window: &AppWindow, state: &Arc<Mutex<AppState>>, event: AppEv
render_queue(window, state); render_queue(window, state);
render_current_track(window, state); render_current_track(window, state);
} }
if similarity_changed {
render::render_similarity(window, state);
}
render_playback(window, state); render_playback(window, state);
}); });
} }
+217 -27
View File
@@ -3,6 +3,7 @@ pub(super) fn render(window: &AppWindow, state: &AppState) {
render_shell(window, state); render_shell(window, state);
render_catalog(window, state); render_catalog(window, state);
render_search(window, state); render_search(window, state);
render_similarity(window, state);
render_queue(window, state); render_queue(window, state);
render_current_track(window, state); render_current_track(window, state);
render_playback(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_search_label(strings.search.into());
window.set_library_label(strings.library.into()); window.set_library_label(strings.library.into());
window.set_queue_label(strings.queue.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_featured_label(strings.made_for_listening.into());
window.set_search_placeholder(strings.search_placeholder.into()); window.set_search_placeholder(strings.search_placeholder.into());
window.set_active_screen( window.set_active_screen(
@@ -23,6 +24,8 @@ pub(super) fn render_shell(window: &AppWindow, state: &AppState) {
Screen::Home => "home", Screen::Home => "home",
Screen::Search => "search", Screen::Search => "search",
Screen::Library => "library", Screen::Library => "library",
Screen::History => "history",
Screen::Similarity => "similarity",
Screen::Artist(_) => "artist", Screen::Artist(_) => "artist",
Screen::Release(_, _) => "release", Screen::Release(_, _) => "release",
Screen::Playlist(_) => "playlist", 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_library_path(state.backend.settings.library_path.clone().into());
window.set_federation_enabled(state.backend.settings.federation_enabled); window.set_federation_enabled(state.backend.settings.federation_enabled);
window.set_save_federated_on_listen(state.backend.settings.save_federated_on_listen); 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_selected_language(state.backend.settings.language.clone().into());
window.set_available_languages(model(vec![SharedString::from("English")])); window.set_available_languages(model(vec![SharedString::from("English")]));
window.set_search_query(state.frontend.search_query.clone().into()); 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); render_device_shell(window, state);
let (network_busy, network_text) = federation_status(state); let (network_busy, network_text) = federation_status(state);
window.set_federation_status_busy(network_busy); 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()); window.set_federation_status_text(network_text.into());
render_federation_debug(window, state); render_federation_debug(window, state);
render_build_info(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); 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) { fn render_federation_debug(window: &AppWindow, state: &AppState) {
let debug = &state.backend.federation_debug; let debug = &state.backend.federation_debug;
window.set_federation_debug_node( window.set_federation_debug_node(
@@ -184,13 +240,27 @@ fn render_device_shell(window: &AppWindow, state: &AppState) {
devices devices
.pending_pairings .pending_pairings
.iter() .iter()
.map(|pending| PairingView { .map(|pending| {
request_id: pending.request_id.clone().into(), let group_conflict = pending.requester_group_id.is_some()
name: pending.name.clone().into(), && pending.requester_group_active_devices > 1;
details: format!("Furumi {} · {}", pending.client_version, pending.device_id) PairingView {
.into(), request_id: pending.request_id.clone().into(),
group_conflict: pending.requester_group_id.is_some() name: pending.name.clone().into(),
&& pending.requester_group_active_devices > 1, 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(), .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(|library| library.featured_releases.iter())
.flat_map(|release| release.tracks.iter()), .flat_map(|release| release.tracks.iter()),
) )
.chain(
ready_library(state)
.into_iter()
.flat_map(|library| library.recently_played.iter()),
)
.chain( .chain(
ready_library(state) ready_library(state)
.into_iter() .into_iter()
.flat_map(|library| library.playlists.iter()) .flat_map(|library| library.playlists.iter())
.flat_map(|playlist| playlist.tracks.iter()), .flat_map(|playlist| playlist.tracks.iter()),
) )
.chain(state.backend.similarity_search.results.iter())
.find(|track| track.key.matches(key)) .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 { let tracks: Vec<Track> = match context {
"release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks), "release" => selected_release(state).map_or_else(Vec::new, |release| release.tracks),
"search" => state.backend.search.results.tracks.clone(), "search" => state.backend.search.results.tracks.clone(),
"recent" => ready_library(state) "history" => ready_library(state)
.into_iter() .into_iter()
.flat_map(|library| library.recently_played.iter()) .flat_map(|library| library.recently_played.iter())
.cloned() .cloned()
.collect(), .collect(),
"similarity" => state.backend.similarity_search.results.clone(),
"artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| { "artist-featured" => selected_artist(state).map_or_else(Vec::new, |artist| {
ready_library(state) ready_library(state)
.into_iter() .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() 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() { match target.as_str() {
"home" => Some(Screen::Home), "home" => Some(Screen::Home),
"search" => Some(Screen::Search), "search" => Some(Screen::Search),
"library" => Some(Screen::Library), "library" => Some(Screen::Library),
"history" => Some(Screen::History),
value if value.starts_with("playlist:") => value value if value.starts_with("playlist:") => value
.trim_start_matches("playlist:") .trim_start_matches("playlist:")
.parse::<i64>() .parse::<i64>()
.ok() .ok()
.map(Screen::Playlist), .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("Home".into(), "home".into(), true),
crumb("Library".into(), String::new(), false), 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![ Screen::Artist(_) => vec![
crumb("Home".into(), "home".into(), true), crumb("Home".into(), "home".into(), true),
crumb( crumb(
@@ -588,7 +674,11 @@ pub(super) fn render_catalog(window: &AppWindow, state: &AppState) {
}); });
window.set_releases(model(releases)); window.set_releases(model(releases));
let artists = library.map_or_else(Vec::new, |library| { 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)); 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); 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) tracks_to_views(&library.recently_played, state)
}); });
window.set_tracks(model(recent)); window.set_history_tracks(model(history));
let playlist = selected_playlist(state); let playlist = selected_playlist(state);
window.set_playlist_title( window.set_playlist_title(
playlist 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()); let (artwork, has_artwork) = load_artwork(item.track.cover_uri.as_deref());
QueueView { QueueView {
key: item.id.get().to_string().into(), key: item.id.get().to_string().into(),
track_key: format_track_key(&item.track.key).into(),
title: item.track.title.clone().into(), title: item.track.title.clone().into(),
artist: item.track.artist.clone().into(), artist: item.track.artist.clone().into(),
artist_key: item artist_key: item
@@ -753,6 +844,7 @@ pub(super) fn render_queue(window: &AppWindow, state: &AppState) {
release: item.track.release.clone().into(), release: item.track.release.clone().into(),
release_key: format_release_key(&item.track.release_id).into(), release_key: format_release_key(&item.track.release_id).into(),
active: state.backend.queue.current_index() == Some(index), active: state.backend.queue.current_index() == Some(index),
liked: item.track.liked,
artwork, artwork,
has_artwork, has_artwork,
} }
@@ -770,6 +862,12 @@ pub(super) fn render_playback(window: &AppWindow, state: &AppState) {
window.set_duration(player.duration.into()); window.set_duration(player.duration.into());
window.set_progress(player.progress); window.set_progress(player.progress);
window.set_volume(player.volume); window.set_volume(player.volume);
window.set_shuffle_enabled(player.shuffle);
window.set_repeat_mode(match player.repeat {
PlaybackRepeat::Off => 0,
PlaybackRepeat::One => 1,
PlaybackRepeat::All => 2,
});
} }
pub(super) fn render_current_track(window: &AppWindow, state: &AppState) { pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
@@ -803,7 +901,12 @@ pub(super) fn render_current_track(window: &AppWindow, state: &AppState) {
pub(super) fn render_search(window: &AppWindow, state: &AppState) { pub(super) fn render_search(window: &AppWindow, state: &AppState) {
let search = &state.backend.search; let search = &state.backend.search;
window.set_search_artists(model( 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( window.set_search_releases(model(
search search
@@ -816,6 +919,14 @@ pub(super) fn render_search(window: &AppWindow, state: &AppState) {
window.set_search_results(model(tracks_to_views(&search.results.tracks, state))); 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 { fn search_duration_label(milliseconds: u64) -> String {
if milliseconds < 1_000 { if milliseconds < 1_000 {
format!("{milliseconds} ms") format!("{milliseconds} ms")
@@ -1022,8 +1133,13 @@ pub(super) fn contributor_lines(artists: &[ArtistRef], width: f32) -> Vec<Artist
.collect() .collect()
} }
fn artist_to_view(artist: &Artist) -> ArtistView { fn artist_to_view(artist: &Artist, releases: &[Release]) -> ArtistView {
let (artwork, has_artwork) = load_artwork(artist.artwork.uri.as_deref()); let artwork_uri = artist
.artwork
.uri
.as_deref()
.or_else(|| fallback_artist_artwork(artist, releases));
let (artwork, has_artwork) = load_artwork(artwork_uri);
ArtistView { ArtistView {
key: format_artist_key(&artist.key).into(), key: format_artist_key(&artist.key).into(),
name: artist.name.clone().into(), name: artist.name.clone().into(),
@@ -1038,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 { fn release_to_view(release: &Release) -> ReleaseView {
let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref()); let (artwork, has_artwork) = load_artwork(release.artwork.uri.as_deref());
ReleaseView { ReleaseView {
@@ -1290,16 +1430,21 @@ fn format_release_key(key: &ReleaseKey) -> String {
} }
} }
pub(super) fn find_artist_key(state: &AppState, value: &SharedString) -> Option<ArtistKey> { pub(super) fn parse_artist_key(value: &SharedString) -> Option<ArtistKey> {
let local = match &state.backend.library { if let Some(local) = value.strip_prefix("local-artist:") {
RemoteData::Ready(library) => library.artists.as_slice(), return local
_ => &[], .parse::<i64>()
}; .ok()
local .map(|id| ArtistKey::local(furumi_domain::ArtistId::new(id)));
.iter() }
.chain(state.backend.search.results.artists.iter()) let (peer_id, id) = value.strip_prefix("fed-artist:")?.split_once(':')?;
.find(|artist| format_artist_key(&artist.key) == value.as_str()) if peer_id.is_empty() || id.is_empty() {
.map(|artist| artist.key.clone()) 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> { pub(super) fn find_release_key(state: &AppState, value: &SharedString) -> Option<ReleaseKey> {
@@ -1374,6 +1519,51 @@ mod tests {
assert_eq!(decoded.size().height, 1); 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] #[test]
fn release_credits_include_artists_found_only_on_tracks() { fn release_credits_include_artists_found_only_on_tracks() {
let pasha = ArtistRef { let pasha = ArtistRef {
+414 -55
View File
@@ -1,9 +1,41 @@
import { Button, CheckBox, ComboBox, LineEdit, ScrollView, Slider } from "std-widgets.slint"; 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 { 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 { export component AppWindow inherits Window {
title: "Furumi Desktop"; title: "Furumi Desktop";
icon: @image-url("../assets/federation.svg");
preferred-width: 1180px; preferred-width: 1180px;
preferred-height: 760px; preferred-height: 760px;
min-width: 920px; min-width: 920px;
@@ -16,7 +48,7 @@ export component AppWindow inherits Window {
in property <string> search-label: "Search"; in property <string> search-label: "Search";
in property <string> library-label: "Your library"; in property <string> library-label: "Your library";
in property <string> queue-label: "Queue"; 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> featured-label: "Made for listening";
in property <string> search-placeholder: "Artists, albums or tracks"; in property <string> search-placeholder: "Artists, albums or tracks";
in-out property <string> search-query; in-out property <string> search-query;
@@ -28,6 +60,19 @@ export component AppWindow inherits Window {
in property <string> library-path; in property <string> library-path;
in property <bool> federation-enabled; in property <bool> federation-enabled;
in property <bool> save-federated-on-listen; 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> selected-language: "English";
in property <[string]> available-languages; in property <[string]> available-languages;
in property <[ReleaseView]> releases; in property <[ReleaseView]> releases;
@@ -43,8 +88,15 @@ export component AppWindow inherits Window {
in property <string> detail-release-type; in property <string> detail-release-type;
in property <image> detail-artwork; in property <image> detail-artwork;
in property <bool> detail-has-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; 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 <[PlaylistView]> playlists;
in property <[TrackView]> playlist-tracks; in property <[TrackView]> playlist-tracks;
in property <string> playlist-title; in property <string> playlist-title;
@@ -62,6 +114,7 @@ export component AppWindow inherits Window {
in property <[ArtistView]> search-artists; in property <[ArtistView]> search-artists;
in property <[ReleaseView]> search-releases; in property <[ReleaseView]> search-releases;
in property <bool> federation-status-busy; in property <bool> federation-status-busy;
in property <bool> federation-status-running;
in property <string> federation-status-text: "Federation ready"; in property <string> federation-status-text: "Federation ready";
in property <string> federation-debug-node: "Stopped"; in property <string> federation-debug-node: "Stopped";
in property <string> federation-debug-peers: "0 connected · 0 known"; in property <string> federation-debug-peers: "0 connected · 0 known";
@@ -99,6 +152,9 @@ export component AppWindow inherits Window {
in property <string> duration; in property <string> duration;
in property <float> progress; in property <float> progress;
in property <float> volume; in property <float> volume;
in property <bool> shuffle-enabled;
// 0 = off, 1 = current track, 2 = entire queue.
in property <int> repeat-mode;
in property <string> error-message; in property <string> error-message;
callback navigate(string); callback navigate(string);
@@ -113,12 +169,16 @@ export component AppWindow inherits Window {
callback toggle-playback; callback toggle-playback;
callback next; callback next;
callback previous; callback previous;
callback toggle-shuffle;
callback cycle-repeat;
callback seek(float); callback seek(float);
callback set-volume(float); callback set-volume(float);
callback play-release(string); callback play-release(string);
callback play-track(string); callback play-track(string);
callback play-track-context(string, string); callback play-track-context(string, string);
callback play-queue-item(string); callback play-queue-item(string);
callback move-queue-item(string, int);
callback remove-queue-item(string);
callback track-action(string, string); callback track-action(string, string);
callback open-playlist(string); callback open-playlist(string);
callback create-playlist(string); callback create-playlist(string);
@@ -135,6 +195,14 @@ export component AppWindow inherits Window {
callback choose-library-path; callback choose-library-path;
callback federation-changed(bool); callback federation-changed(bool);
callback save-federated-on-listen-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 language-changed(string);
callback dismiss-error; callback dismiss-error;
callback close-track-info; callback close-track-info;
@@ -165,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.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.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.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; } Rectangle { height: 12px; background: transparent; }
HorizontalLayout { HorizontalLayout {
height: 28px; height: 28px;
@@ -191,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(); } 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 { new-playlist-popup := PopupWindow {
@@ -263,7 +324,7 @@ export component AppWindow inherits Window {
border-width: 1px; border-width: 1px;
HorizontalLayout { HorizontalLayout {
padding-left: 10px; padding-right: 10px; spacing: 6px; 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; } status-label := Text { text: root.federation-status-text; color: #9da3b2; font-size: 10px; overflow: elide; vertical-alignment: center; }
} }
} }
@@ -279,9 +340,18 @@ export component AppWindow inherits Window {
Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; } Text { text: "Good evening"; color: #f5f6fa; font-size: 28px; font-weight: 800; }
Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; } Text { text: "Artists"; color: #f5f6fa; font-size: 18px; font-weight: 750; }
ArtistGrid { artists: root.artists; open(key) => root.open-artist(key); } 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); }
} }
} }
@@ -296,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 { if root.active-screen == "playlist": ScrollView {
viewport-width: self.width; viewport-width: self.width;
horizontal-scrollbar-policy: ScrollBarPolicy.always-off; horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
@@ -399,7 +483,7 @@ export component AppWindow inherits Window {
} }
if root.queue-open: queue-panel := Rectangle { if root.queue-open: queue-panel := Rectangle {
width: 292px; width: 370px;
background: #11141b; background: #11141b;
border-color: #252936; border-color: #252936;
border-width: 1px; border-width: 1px;
@@ -413,26 +497,142 @@ export component AppWindow inherits Window {
IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-queue(); } IconButton { icon-source: @image-url("../assets/close.svg"); clicked => root.toggle-queue(); }
} }
Text { text: "Now playing and up next"; color: #777d8d; font-size: 11px; } Text { text: "Now playing and up next"; color: #777d8d; font-size: 11px; }
VerticalLayout { ScrollView {
spacing: 4px; vertical-stretch: 1;
for item in root.queue-items: Rectangle { min-height: 0px;
height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent; viewport-width: self.width;
TouchArea { double-clicked => root.play-queue-item(item.key); } horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
HorizontalLayout { vertical-scrollbar-policy: ScrollBarPolicy.always-off;
padding: 7px; spacing: 10px; mouse-drag-pan-enabled: root.queue-drag-source < 0;
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; } } queue-list := Rectangle {
Rectangle { width: parent.width;
width: 190px; height: parent.height; background: transparent; clip: true; height: root.queue-items.length > 0 ? root.queue-items.length * 56px - 4px : 0px;
VerticalLayout { background: transparent;
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center; for item[index] in root.queue-items: queue-row := Rectangle {
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; } private property <int> visual-index: root.queue-drag-source < 0 ? index
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); } : 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; } }
} }
} }
} }
} }
Rectangle { vertical-stretch: 1; background: transparent; }
} }
} }
} }
@@ -443,10 +643,7 @@ export component AppWindow inherits Window {
border-color: #282b35; border-color: #282b35;
border-width: 1px; border-width: 1px;
HorizontalLayout { HorizontalLayout {
padding-left: 18px; padding-right: 18px; padding-top: 12px; padding-bottom: 10px; x: 18px; y: 12px; width: 260px; height: 68px; spacing: 11px;
spacing: 18px;
HorizontalLayout {
width: 260px; spacing: 11px;
Rectangle { Rectangle {
width: 54px; height: 54px; border-radius: 6px; background: #292d38; clip: true; width: 54px; height: 54px; border-radius: 6px; background: #292d38; clip: true;
Image { source: @image-url("../assets/note.svg"); width: 28px; height: 28px; x: 13px; y: 13px; } Image { source: @image-url("../assets/note.svg"); width: 28px; height: 28px; x: 13px; y: 13px; }
@@ -460,11 +657,24 @@ export component AppWindow inherits Window {
ArtistLinksRow { width: 195px; height: 16px; artists: root.current-artists; row-color: #818797; open-artist(key) => root.open-artist(key); } ArtistLinksRow { width: 195px; height: 16px; artists: root.current-artists; row-color: #818797; open-artist(key) => root.open-artist(key); }
Text { width: 195px; text: root.current-metadata; color: #656b7b; font-size: 10px; overflow: elide; vertical-alignment: center; } Text { width: 195px; text: root.current-metadata; color: #656b7b; font-size: 10px; overflow: elide; vertical-alignment: center; }
} }
} }
Rectangle {
// Keep transport controls at the true center of the window.
// The bar grows on compact windows but stops at a comfortable width.
width: min(560px, max(320px, parent.width - 570px));
height: 68px;
x: (parent.width - self.width) / 2;
y: 10px;
background: transparent;
VerticalLayout { VerticalLayout {
horizontal-stretch: 1; spacing: 7px; spacing: 7px;
HorizontalLayout { HorizontalLayout {
alignment: center; spacing: 8px; alignment: center; spacing: 8px;
IconButton {
icon-source: @image-url("../assets/shuffle.svg");
icon-color: root.shuffle-enabled ? #e58bd7 : transparent;
clicked => root.toggle-shuffle();
}
IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); } IconButton { icon-source: @image-url("../assets/previous.svg"); clicked => root.previous(); }
Rectangle { Rectangle {
width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8; width: 42px; height: 42px; border-radius: 21px; background: #f4f5f8;
@@ -473,6 +683,22 @@ export component AppWindow inherits Window {
TouchArea { clicked => root.toggle-playback(); } TouchArea { clicked => root.toggle-playback(); }
} }
IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); } IconButton { icon-source: @image-url("../assets/next.svg"); clicked => root.next(); }
Rectangle {
width: 38px; height: 38px; background: transparent;
IconButton {
icon-source: @image-url("../assets/repeat.svg");
icon-color: root.repeat-mode != 0 ? #e58bd7 : transparent;
clicked => root.cycle-repeat();
}
if root.repeat-mode == 1: Rectangle {
x: 24px; y: 23px; width: 12px; height: 12px; border-radius: 6px;
background: #11131a;
Text {
text: "1"; color: #e58bd7; font-size: 8px; font-weight: 800;
horizontal-alignment: center; vertical-alignment: center;
}
}
}
} }
HorizontalLayout { HorizontalLayout {
spacing: 9px; spacing: 9px;
@@ -481,8 +707,9 @@ export component AppWindow inherits Window {
Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; } Text { text: root.duration; color: #818797; font-size: 10px; width: 34px; vertical-alignment: center; }
} }
} }
Rectangle { }
width: 238px; background: transparent; Rectangle {
x: parent.width - 256px; y: 12px; width: 238px; height: 68px; background: transparent;
IconButton { x: 0px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/queue.svg"); clicked => root.toggle-queue(); } IconButton { x: 0px; y: (parent.height - self.height) / 2; icon-source: @image-url("../assets/queue.svg"); clicked => root.toggle-queue(); }
Image { x: 48px; y: (parent.height - self.height) / 2; source: @image-url("../assets/volume.svg"); width: 18px; height: 18px; } Image { x: 48px; y: (parent.height - self.height) / 2; source: @image-url("../assets/volume.svg"); width: 18px; height: 18px; }
Slider { x: 75px; y: (parent.height - self.height) / 2; width: 100px; height: 18px; minimum: 0; maximum: 1; value: root.volume; changed(value) => root.set-volume(value); } Slider { x: 75px; y: (parent.height - self.height) / 2; width: 100px; height: 18px; minimum: 0; maximum: 1; value: root.volume; changed(value) => root.set-volume(value); }
@@ -524,6 +751,28 @@ export component AppWindow inherits Window {
vertical-scrollbar-policy: ScrollBarPolicy.always-off; vertical-scrollbar-policy: ScrollBarPolicy.always-off;
VerticalLayout { VerticalLayout {
spacing: 4px; 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 { for device in root.connected-devices: DeviceRow {
visible: !device.active; visible: !device.active;
height: !device.active ? 48px : 0px; height: !device.active ? 48px : 0px;
@@ -531,23 +780,31 @@ export component AppWindow inherits Window {
selectable: !device.revoked && (device.online || device.is-self); selectable: !device.revoked && (device.online || device.is-self);
selected(id) => root.select-device(id); 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; } 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 { HorizontalLayout {
spacing: 8px; spacing: 8px;
device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; } device-link := LineEdit { horizontal-stretch: 1; placeholder-text: "Paste frid://i/… invite"; }
@@ -561,7 +818,6 @@ export component AppWindow inherits Window {
} }
} }
} }
}
} }
} }
} }
@@ -665,7 +921,7 @@ export component AppWindow inherits Window {
spacing: 8px; spacing: 8px;
LineEdit { LineEdit {
text: root.library-path; text: root.library-path;
placeholder-text: "~/Music/Furumi"; placeholder-text: "Choose a music directory";
horizontal-stretch: 1; horizontal-stretch: 1;
edited => root.library-path-changed(self.text); edited => root.library-path-changed(self.text);
} }
@@ -707,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 { HorizontalLayout {
height: 52px; height: 52px;
VerticalLayout { VerticalLayout {
+52 -4
View File
@@ -15,6 +15,49 @@ export component IconButton inherits Rectangle {
touch := TouchArea { enabled: root.enabled; clicked => root.clicked(); } 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 { export component DeviceRow inherits Rectangle {
in property <DeviceView> item; in property <DeviceView> item;
in property <bool> selectable: false; in property <bool> selectable: false;
@@ -146,9 +189,14 @@ export component PlaylistNavButton inherits Rectangle {
background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent; background: root.active ? #252936 : touch.has-hover ? #1a1d27 : transparent;
HorizontalLayout { HorizontalLayout {
padding-left: 10px; padding-right: 8px; spacing: 9px; padding-left: 10px; padding-right: 8px; spacing: 9px;
Image { Rectangle {
source: root.item.is-likes ? @image-url("../assets/heart.svg") : @image-url("../assets/playlist-add.svg"); width: 15px;
width: 15px; height: 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; } 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 { VerticalLayout {
padding: 6px; spacing: 1px; 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/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); } } TrackMenuItem { icon-source: @image-url("../assets/playlist-add.svg"); label: "Add to playlist"; clicked => { menu.close(); root.action("playlist", root.item.key); } }
} }
} }
+3
View File
@@ -55,12 +55,14 @@ export struct TrackView {
export struct QueueView { export struct QueueView {
key: string, key: string,
track-key: string,
title: string, title: string,
artist: string, artist: string,
artist-key: string, artist-key: string,
release: string, release: string,
release-key: string, release-key: string,
active: bool, active: bool,
liked: bool,
artwork: image, artwork: image,
has-artwork: bool, has-artwork: bool,
} }
@@ -87,4 +89,5 @@ export struct PairingView {
name: string, name: string,
details: string, details: string,
group-conflict: bool, group-conflict: bool,
group-summary: string,
} }
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Furumi Desktop
Comment=Native player for personal music libraries and the Furumi network
Exec=furumi-desktop
Icon=furumi-desktop
Terminal=false
Categories=AudioVideo;Audio;Player;
StartupNotify=true
+7 -1
View File
@@ -10,6 +10,7 @@ pkgs.mkShell {
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
alsa-lib
fontconfig fontconfig
freetype freetype
libxkbcommon libxkbcommon
@@ -22,6 +23,7 @@ pkgs.mkShell {
]; ];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [
alsa-lib
fontconfig fontconfig
freetype freetype
libxkbcommon libxkbcommon
@@ -33,6 +35,10 @@ pkgs.mkShell {
xorg.libXrandr 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"; RUST_BACKTRACE = "1";
} }