Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
Generated
+13
-7
@@ -250,6 +250,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",
|
||||||
@@ -2110,7 +2111,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-application"
|
name = "furumi-application"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"furumi-backend-api",
|
"furumi-backend-api",
|
||||||
"furumi-domain",
|
"furumi-domain",
|
||||||
@@ -2118,7 +2119,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-backend"
|
name = "furumi-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -2136,14 +2137,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-backend-api"
|
name = "furumi-backend-api"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"furumi-domain",
|
"furumi-domain",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-desktop"
|
name = "furumi-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"furumi-backend",
|
"furumi-backend",
|
||||||
"furumi-ui",
|
"furumi-ui",
|
||||||
@@ -2151,7 +2152,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-domain"
|
name = "furumi-domain"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-library"
|
name = "furumi-library"
|
||||||
@@ -2172,9 +2173,12 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-platform-desktop"
|
name = "furumi-platform-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
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 +2186,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumi-ui"
|
name = "furumi-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"furumi-application",
|
"furumi-application",
|
||||||
"furumi-backend",
|
"furumi-backend",
|
||||||
@@ -7662,6 +7666,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",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -9238,6 +9243,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",
|
||||||
|
|||||||
+9
-2
@@ -11,7 +11,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.97"
|
rust-version = "1.97"
|
||||||
license = "WTFPL"
|
license = "WTFPL"
|
||||||
@@ -36,7 +36,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",
|
||||||
@@ -49,6 +49,13 @@ rodio = { version = "0.22.2", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! 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::{
|
||||||
|
BackendCommand, BackendSnapshot, PlaybackRepeat, PlaybackStatus, RequestId,
|
||||||
|
};
|
||||||
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
|
use furumi_domain::{ArtistKey, QueueItemId, ReleaseKey, TrackKey};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -105,6 +107,8 @@ pub enum UiAction {
|
|||||||
TogglePlayback,
|
TogglePlayback,
|
||||||
Next,
|
Next,
|
||||||
Previous,
|
Previous,
|
||||||
|
ToggleShuffle,
|
||||||
|
CycleRepeat,
|
||||||
Seek(f64),
|
Seek(f64),
|
||||||
SetVolume(f32),
|
SetVolume(f32),
|
||||||
PlayRelease {
|
PlayRelease {
|
||||||
@@ -258,6 +262,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 } => {
|
||||||
@@ -433,6 +439,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 +458,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,6 +274,8 @@ pub enum BackendCommand {
|
|||||||
SetVolume {
|
SetVolume {
|
||||||
volume: f32,
|
volume: f32,
|
||||||
},
|
},
|
||||||
|
ToggleShuffle,
|
||||||
|
CycleRepeat,
|
||||||
PlayRelease {
|
PlayRelease {
|
||||||
release_id: ReleaseKey,
|
release_id: ReleaseKey,
|
||||||
start: usize,
|
start: usize,
|
||||||
|
|||||||
@@ -265,8 +265,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 +311,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 +361,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 +374,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 +509,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
VersionEntrySnapshot,
|
||||||
};
|
};
|
||||||
use furumi_domain::{
|
use furumi_domain::{
|
||||||
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId,
|
Artist, ArtistId, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId,
|
||||||
@@ -688,9 +689,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 +715,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();
|
||||||
}
|
}
|
||||||
@@ -712,6 +730,7 @@ impl Actor {
|
|||||||
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 +827,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1446,7 @@ impl Actor {
|
|||||||
}
|
}
|
||||||
audio::Event::Finished => {
|
audio::Event::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;
|
||||||
@@ -1476,6 +1495,23 @@ impl Actor {
|
|||||||
self.publish();
|
self.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.state.playback_error = Some("federation is still starting".into());
|
self.state.playback_error = Some("federation is still starting".into());
|
||||||
|
|||||||
@@ -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>) {
|
||||||
@@ -425,6 +427,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 +589,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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,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 |
+20
-1
@@ -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,
|
||||||
@@ -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| {
|
||||||
@@ -357,6 +364,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);
|
||||||
|
|||||||
@@ -770,6 +770,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) {
|
||||||
|
|||||||
+64
-24
@@ -4,6 +4,7 @@ import { AlbumGrid, ArtistGrid, ArtistLinksRow, BuildInfoCard, DeviceRow, Federa
|
|||||||
|
|
||||||
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;
|
||||||
@@ -99,6 +100,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,6 +117,8 @@ 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);
|
||||||
@@ -413,26 +419,34 @@ 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;
|
VerticalLayout {
|
||||||
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; } }
|
width: parent.width;
|
||||||
Rectangle {
|
spacing: 4px;
|
||||||
width: 190px; height: parent.height; background: transparent; clip: true;
|
alignment: start;
|
||||||
VerticalLayout {
|
for item in root.queue-items: Rectangle {
|
||||||
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
|
height: 52px; border-radius: 7px; background: item.active ? #242a35 : transparent;
|
||||||
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
|
TouchArea { double-clicked => root.play-queue-item(item.key); }
|
||||||
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
|
HorizontalLayout {
|
||||||
|
padding: 7px; spacing: 10px;
|
||||||
|
Rectangle { width: 38px; height: 38px; border-radius: 5px; background: #292d38; Image { source: @image-url("../assets/note.svg"); width: 21px; height: 21px; x: 8px; y: 8px; } if item.has-artwork: Image { source: item.artwork; image-fit: ImageFit.cover; width: parent.width; height: parent.height; } }
|
||||||
|
Rectangle {
|
||||||
|
width: 190px; height: parent.height; background: transparent; clip: true;
|
||||||
|
VerticalLayout {
|
||||||
|
x: 0px; y: 0px; width: parent.width; height: parent.height; alignment: center;
|
||||||
|
MarqueeText { width: parent.width; height: 18px; label: item.title; text-color: item.active ? #55dbaa : #e7e9ef; text-size: 12px; text-weight: 600; }
|
||||||
|
LinkText { width: parent.width; label: item.artist; base-color: #777d8d; text-size: 10px; height: 15px; clicked => root.open-artist(item.artist-key); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Rectangle { vertical-stretch: 1; background: transparent; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,10 +457,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 +471,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 +497,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 +521,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); }
|
||||||
@@ -561,7 +602,6 @@ export component AppWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -35,4 +37,3 @@ pkgs.mkShell {
|
|||||||
|
|
||||||
RUST_BACKTRACE = "1";
|
RUST_BACKTRACE = "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user