7 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 2dc40c2c3f fixed ci 2026-08-13 16:50:06 +01:00
Aleksandr Bogomiakov f6d8cb0631 Fix rfd portal runtime feature 2026-08-13 16:19:15 +01:00
Aleksandr Bogomiakov 728ca708b2 Added appimage 2026-08-13 15:34:07 +01:00
Ultradesu aa392e8f7f fix lock 2026-08-13 12:12:10 +01:00
Ultradesu 4714cd77db CI 2026-08-13 12:01:27 +01:00
Ultradesu c2ca09aecf It works 2026-08-13 11:55:35 +01:00
Ultradesu e26159f85f Added app icon 2026-08-12 01:56:21 +01:00
19 changed files with 681 additions and 47 deletions
+250
View File
@@ -0,0 +1,250 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
jobs:
build:
name: Build ${{ matrix.asset_name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
# AppImages must be built on the oldest supported distribution so
# that their glibc requirement remains compatible with newer ones.
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
asset_name: furumi-desktop-linux-x86_64
archive_ext: AppImage
binary_name: furumi-desktop
- os: macos-latest
target: aarch64-apple-darwin
asset_name: furumi-desktop-macos-aarch64
archive_ext: tar.gz
binary_name: furumi-desktop
- os: windows-latest
target: x86_64-pc-windows-msvc
asset_name: furumi-desktop-windows-x86_64
archive_ext: zip
binary_name: furumi-desktop.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libasound2-dev \
libegl1-mesa-dev \
libfontconfig1-dev \
libfreetype-dev \
libgl1-mesa-dev \
libudev-dev \
libwayland-dev \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libxcursor-dev \
libxi-dev \
libxrandr-dev \
pkg-config
- name: Install Rust target
shell: bash
run: rustup target add "${{ matrix.target }}"
- name: Show Rust version
shell: bash
run: |
rustc --version --verbose
cargo --version
- name: Fetch locked dependencies
shell: bash
run: cargo fetch --locked --target "${{ matrix.target }}"
- name: Build desktop application
shell: bash
run: |
cargo build \
--release \
--locked \
--offline \
--package furumi-desktop \
--target "${{ matrix.target }}"
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}"
package_dir="dist/${{ matrix.asset_name }}"
binary="target/${{ matrix.target }}/release/${{ matrix.binary_name }}"
if [[ "${{ runner.os }}" == "Linux" ]]; then
app_dir="dist/FurumiDesktop.AppDir"
appimage_runtime="$RUNNER_TEMP/appimage-runtime-x86_64"
linuxdeploy="$RUNNER_TEMP/linuxdeploy-x86_64.AppImage"
linuxdeploy_dir="$RUNNER_TEMP/linuxdeploy"
linuxdeploy_version="1-alpha-20251107-1"
linuxdeploy_sha256="c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d"
runtime_version="20251108"
runtime_sha256="2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d"
mkdir -p "$app_dir/usr/share/doc/furumi-desktop"
cp README.md LICENSE "$app_dir/usr/share/doc/furumi-desktop/"
curl --fail --location --retry 3 --silent --show-error \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/${linuxdeploy_version}/linuxdeploy-x86_64.AppImage" \
--output "$linuxdeploy"
echo "${linuxdeploy_sha256} ${linuxdeploy}" | sha256sum --check
chmod +x "$linuxdeploy"
curl --fail --location --retry 3 --silent --show-error \
"https://github.com/AppImage/type2-runtime/releases/download/${runtime_version}/runtime-x86_64" \
--output "$appimage_runtime"
echo "${runtime_sha256} ${appimage_runtime}" | sha256sum --check
APPIMAGE_EXTRACT_AND_RUN=1 "$linuxdeploy" \
--appdir "$app_dir" \
--executable "$binary" \
--desktop-file packaging/linux/furumi-desktop.desktop \
--icon-file crates/ui/assets/federation.svg \
--icon-filename furumi-desktop
mkdir -p "$linuxdeploy_dir"
(
cd "$linuxdeploy_dir"
"$linuxdeploy" --appimage-extract >/dev/null
)
appimagetool="$linuxdeploy_dir/squashfs-root/plugins/linuxdeploy-plugin-appimage/appimagetool-prefix/AppRun"
ARCH=x86_64 VERSION="$version" "$appimagetool" \
--no-appstream \
--runtime-file "$appimage_runtime" \
"$app_dir" \
"$archive_name"
chmod +x "$archive_name"
"./$archive_name" --appimage-extract >/dev/null
test -x squashfs-root/AppRun
test -x squashfs-root/usr/bin/furumi-desktop
test -f squashfs-root/usr/share/applications/furumi-desktop.desktop
test -f squashfs-root/usr/share/icons/hicolor/scalable/apps/furumi-desktop.svg
rm -rf squashfs-root
elif [[ "${{ runner.os }}" == "macOS" ]]; then
mkdir -p "$package_dir"
app_dir="$package_dir/Furumi Desktop.app"
mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources"
cp "$binary" "$app_dir/Contents/MacOS/furumi-desktop"
iconset="$RUNNER_TEMP/furumi.iconset"
mkdir -p "$iconset"
sips -s format png -z 16 16 crates/ui/assets/federation.svg --out "$iconset/icon_16x16.png" >/dev/null
sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_16x16@2x.png" >/dev/null
sips -s format png -z 32 32 crates/ui/assets/federation.svg --out "$iconset/icon_32x32.png" >/dev/null
sips -s format png -z 64 64 crates/ui/assets/federation.svg --out "$iconset/icon_32x32@2x.png" >/dev/null
sips -s format png -z 128 128 crates/ui/assets/federation.svg --out "$iconset/icon_128x128.png" >/dev/null
sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_128x128@2x.png" >/dev/null
sips -s format png -z 256 256 crates/ui/assets/federation.svg --out "$iconset/icon_256x256.png" >/dev/null
sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_256x256@2x.png" >/dev/null
sips -s format png -z 512 512 crates/ui/assets/federation.svg --out "$iconset/icon_512x512.png" >/dev/null
sips -s format png -z 1024 1024 crates/ui/assets/federation.svg --out "$iconset/icon_512x512@2x.png" >/dev/null
iconutil -c icns "$iconset" -o "$app_dir/Contents/Resources/furumi.icns"
cat > "$app_dir/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Furumi Desktop</string>
<key>CFBundleExecutable</key>
<string>furumi-desktop</string>
<key>CFBundleIdentifier</key>
<string>cy.hexor.furumi-desktop</string>
<key>CFBundleIconFile</key>
<string>furumi.icns</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Furumi Desktop</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
plutil -lint "$app_dir/Contents/Info.plist"
codesign --force --deep --sign - "$app_dir"
codesign --verify --deep --strict "$app_dir"
else
mkdir -p "$package_dir"
cp "$binary" "$package_dir/"
fi
if [[ "${{ runner.os }}" == "Windows" ]]; then
cp README.md LICENSE "$package_dir/"
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
elif [[ "${{ runner.os }}" == "macOS" ]]; then
cp README.md LICENSE "$package_dir/"
tar -C dist -czf "$archive_name" "${{ matrix.asset_name }}"
fi
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}-*
if-no-files-found: error
publish:
name: Publish release assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \
--title "furumi-desktop ${RELEASE_TAG}" \
--generate-notes \
--verify-tag
Generated
+13 -7
View File
@@ -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
View File
@@ -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" }
+11
View File
@@ -63,6 +63,17 @@ nix-shell
cargo run --bin furumi-desktop cargo run --bin furumi-desktop
``` ```
Linux releases are distributed as AppImages. Make the downloaded file
executable and run it directly:
```bash
chmod +x furumi-desktop-linux-x86_64-*.AppImage
./furumi-desktop-linux-x86_64-*.AppImage
```
On NixOS, enable AppImage support with `programs.appimage.enable` and
`programs.appimage.binfmt`, or launch it explicitly with `appimage-run`.
macOS and Windows require no additional system packages. Player settings are macOS and Windows require no additional system packages. Player settings are
available inside the application and are saved automatically. available inside the application and are saved automatically.
+11 -1
View File
@@ -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,
} }
} }
} }
+25
View File
@@ -27,12 +27,33 @@ pub enum PlaybackStatus {
Paused, Paused,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PlaybackRepeat {
#[default]
Off,
One,
All,
}
impl PlaybackRepeat {
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Off => Self::All,
Self::All => Self::One,
Self::One => Self::Off,
}
}
}
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct PlaybackSnapshot { pub struct PlaybackSnapshot {
pub status: PlaybackStatus, pub status: PlaybackStatus,
pub position_seconds: f64, pub position_seconds: f64,
pub duration_seconds: f64, pub duration_seconds: f64,
pub volume: f32, pub volume: f32,
pub shuffle: bool,
pub repeat: PlaybackRepeat,
} }
impl Default for PlaybackSnapshot { impl Default for PlaybackSnapshot {
@@ -42,6 +63,8 @@ impl Default for PlaybackSnapshot {
position_seconds: 0.0, position_seconds: 0.0,
duration_seconds: 0.0, duration_seconds: 0.0,
volume: 0.72, volume: 0.72,
shuffle: false,
repeat: PlaybackRepeat::Off,
} }
} }
} }
@@ -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,
+57 -7
View File
@@ -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,
}
}
+40 -4
View File
@@ -11,8 +11,9 @@ use furumi_backend_api::{
BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot, BackendCommand, BackendSnapshot, BuildInfoSnapshot, ConnectedDeviceSnapshot,
ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust, ConnectedDevicesSnapshot, DevicePlaybackRole, DevicePresence, DeviceTrust,
FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot, FederationActivitySnapshot, FederationDebugSnapshot, FederationOperation, LibrarySnapshot,
PendingPairingSnapshot, PlaybackStatus, PlaylistSnapshot, RemoteData, RequestId, SearchResults, PendingPairingSnapshot, PlaybackRepeat, PlaybackStatus, PlaylistSnapshot, RemoteData,
SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot, VersionEntrySnapshot, RequestId, SearchResults, SearchSnapshot, SearchStats, SendCommandError, SettingsSnapshot,
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());
+108
View File
@@ -326,6 +326,7 @@ pub struct Queue {
items: Vec<QueueItem>, items: Vec<QueueItem>,
current: Option<usize>, current: Option<usize>,
play_next_end: Option<usize>, play_next_end: Option<usize>,
original_order: Option<Vec<TrackKey>>,
next_item_id: u64, next_item_id: u64,
} }
@@ -360,6 +361,7 @@ impl Queue {
self.items = items; self.items = items;
self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1)); self.current = (!self.items.is_empty()).then(|| start.min(self.items.len() - 1));
self.play_next_end = None; self.play_next_end = None;
self.original_order = None;
} }
pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) { pub fn add_to_end(&mut self, tracks: impl IntoIterator<Item = Track>) {
@@ -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);
+3
View File
@@ -11,6 +11,9 @@ souvlaki.workspace = true
[target.'cfg(target_os="macos")'.dependencies] [target.'cfg(target_os="macos")'.dependencies]
core-foundation.workspace = true core-foundation.workspace = true
objc2.workspace = true
objc2-app-kit.workspace = true
objc2-foundation.workspace = true
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys.workspace = true windows-sys.workspace = true
+38
View File
@@ -0,0 +1,38 @@
/// Applies the Furumi federation mark as the native application icon.
///
/// Slint forwards its window icon to Windows and X11. macOS deliberately
/// ignores that window-level API, so its Dock icon is installed through
/// `AppKit` from the same embedded SVG asset. On macOS this must be called
/// from the main event loop after `applicationDidFinishLaunching`.
pub fn set_application_icon() {
if let Err(error) = set_native_application_icon() {
eprintln!("failed to install the Furumi application icon: {error}");
}
}
#[cfg(target_os = "macos")]
fn set_native_application_icon() -> Result<(), &'static str> {
use std::ffi::c_void;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSImage};
use objc2_foundation::NSData;
let main_thread = MainThreadMarker::new().ok_or("not running on the macOS main thread")?;
let svg = include_bytes!("../../ui/assets/federation.svg");
// SAFETY: `dataWithBytes_length` copies `svg`; its pointer is valid for
// the complete duration of the Objective-C call.
let data = unsafe { NSData::dataWithBytes_length(svg.as_ptr().cast::<c_void>(), svg.len()) };
let icon = NSImage::initWithData(main_thread.alloc(), &data)
.ok_or("AppKit could not decode the embedded federation SVG")?;
let application = NSApplication::sharedApplication(main_thread);
// SAFETY: AppKit accepts a live NSImage here and retains it as the
// application's Dock icon. This function is restricted to the main thread.
unsafe { application.setApplicationIconImage(Some(&icon)) };
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn set_native_application_icon() -> Result<(), &'static str> {
Ok(())
}
+2
View File
@@ -2,7 +2,9 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
mod app;
mod media; mod media;
pub use app::set_application_icon;
pub use media::{MediaCommand, MediaSession}; pub use media::{MediaCommand, MediaSession};
/// Opens the platform-native directory picker. /// Opens the platform-native directory picker.
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 2.8 20.2 6 17 9.2"/>
<path d="M3.8 11V9a3 3 0 0 1 3-3h13.4"/>
<path d="M7 21.2 3.8 18 7 14.8"/>
<path d="M20.2 13v2a3 3 0 0 1-3 3H3.8"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#b8bdca" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7h3.4c4.8 0 6.5 10 11.2 10H21"/>
<path d="m18 14 3 3-3 3"/>
<path d="M3 17h3.4c1.8 0 3.1-1.4 4.3-3.2"/>
<path d="M13.4 9.4C14.6 8 15.9 7 17.6 7H21"/>
<path d="m18 4 3 3-3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

+20 -1
View File
@@ -14,7 +14,7 @@ use furumi_application::{
use furumi_backend::BackendHandle; use furumi_backend::BackendHandle;
use furumi_backend_api::{ use furumi_backend_api::{
BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation, BackendCommand, DevicePlaybackRole, DevicePresence, DeviceTrust, FederationOperation,
PlaybackStatus, RemoteData, PlaybackRepeat, PlaybackStatus, RemoteData,
}; };
use furumi_domain::{ use furumi_domain::{
Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId, Artist, ArtistKey, ArtistRef, AudioSource, CatalogSource, ContentId, LocalTrackId, QueueItemId,
@@ -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);
+6
View File
@@ -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
View File
@@ -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 {
} }
} }
} }
}
} }
} }
} }
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Furumi Desktop
Comment=Native player for personal music libraries and the Furumi network
Exec=furumi-desktop
Icon=furumi-desktop
Terminal=false
Categories=AudioVideo;Audio;Player;
StartupNotify=true
+2 -1
View File
@@ -10,6 +10,7 @@ pkgs.mkShell {
]; ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
alsa-lib
fontconfig fontconfig
freetype freetype
libxkbcommon libxkbcommon
@@ -22,6 +23,7 @@ pkgs.mkShell {
]; ];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; [
alsa-lib
fontconfig fontconfig
freetype freetype
libxkbcommon libxkbcommon
@@ -35,4 +37,3 @@ pkgs.mkShell {
RUST_BACKTRACE = "1"; RUST_BACKTRACE = "1";
} }