Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69bba8e444 | ||
|
|
0d7c9b9f5e | ||
|
|
2dc40c2c3f | ||
|
|
f6d8cb0631 | ||
|
|
728ca708b2 | ||
|
|
aa392e8f7f | ||
|
|
4714cd77db | ||
|
|
c2ca09aecf | ||
|
|
e26159f85f |
@@ -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
@@ -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
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
|||||||
@@ -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<()> {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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); } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user