diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 7cdca49..bc81c15 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -16,7 +16,7 @@ body:
attributes:
label: QuickPlay version
description: From the settings page (gear in the title bar) or `waveflow-quickplay --version`.
- placeholder: "1.0.0"
+ placeholder: "1.1.0"
validations:
required: true
@@ -31,8 +31,8 @@ body:
- Linux (KDE Plasma on Wayland)
- Linux (X11 — any desktop)
- Linux (other, specify below)
- - macOS (Apple Silicon, built from source)
- - macOS (Intel, built from source)
+ - macOS (Apple Silicon)
+ - macOS (Intel)
- Other (specify in the steps)
validations:
required: true
@@ -44,7 +44,9 @@ body:
options:
- GitHub Releases (Windows installer .exe)
- GitHub Releases (.deb)
+ - GitHub Releases (.rpm)
- GitHub Releases (.AppImage)
+ - GitHub Releases (macOS .dmg)
- Built from source (`cargo build --release`)
validations:
required: true
@@ -82,8 +84,8 @@ body:
label: Steps to reproduce
description: Numbered steps a stranger could follow on a fresh install.
placeholder: |
- 1. Install QuickPlay 1.0.0 with the Windows installer.
- 2. Right-click a 24-bit FLAC → "Play with WaveFlow QuickPlay".
+ 1. Install QuickPlay 1.1.0 with the Windows installer.
+ 2. Right-click a 24-bit FLAC → "Play with QuickPlay".
3. Observe the status line.
validations:
required: true
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 52b5b11..c21ccba 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -29,7 +29,7 @@ Your report should include:
- a clear description of the issue;
- the expected impact on users or their files;
- the affected version, platform, and installation mode (installer, `.deb`,
- AppImage, built from source);
+ `.rpm`, AppImage, `.dmg`, built from source);
- the affected surface: a file format, the single-instance socket, the
file-manager registration, the installer, a user flow;
- detailed reproduction steps, with a sample file when the issue is in a
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 47e5800..20aaff8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
-# Format, lint and test on every push and pull request, on the two platforms
-# QuickPlay is released for. The toolchain comes from rust-toolchain.toml;
-# waveflow-core is fetched from the pinned WaveFlow commit.
+# Format, lint and test on every push and pull request, on the three
+# platforms QuickPlay is released for. The toolchain comes from
+# rust-toolchain.toml; waveflow-core is fetched from the pinned WaveFlow
+# commit.
name: CI
on:
@@ -21,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [windows-latest, ubuntu-24.04]
+ os: [windows-latest, ubuntu-24.04, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ed99fc7..cf9b4cc 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,14 +1,14 @@
# Builds the installers for a version tag (`v1.0.0`) and publishes them as a
# GitHub release: a per-user NSIS installer for Windows, a .deb, an .rpm and
-# an AppImage for Linux, with SHA-256 checksums and the notes from
-# docs/releases/.md. Nothing is built unless the tag matches Cargo.toml
-# and the tests pass.
+# an AppImage for Linux, a universal disk image for macOS, with SHA-256
+# checksums and the notes from docs/releases/.md. Nothing is built
+# unless the tag matches Cargo.toml and the tests pass.
#
# It can also be run by hand from `main` for an existing tag (Actions ›
# Release › Run workflow), with the workflow definition of `main` and the
# sources of the tag: that is how a package format added after a release
-# gets attached to it. `linux_only` skips the Windows job so the signed
-# installer already published is left untouched; files that are rebuilt
+# gets attached to it. `linux_only` skips the Windows and macOS jobs so the
+# signed builds already published are left untouched; files that are rebuilt
# replace their namesake on the release.
name: Release
@@ -39,7 +39,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
@@ -58,7 +58,7 @@ jobs:
runs-on: windows-latest
timeout-minutes: 90
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
@@ -146,7 +146,7 @@ jobs:
"{0} {1}" -f (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower(), $_.Name
} | Set-Content target/packager/SHA256SUMS-windows.txt -Encoding ascii
Get-Content target/packager/SHA256SUMS-windows.txt
- - uses: actions/upload-artifact@v7
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows
path: |
@@ -163,7 +163,7 @@ jobs:
# linuxdeploy and appimagetool are AppImages themselves; no FUSE on runners.
APPIMAGE_EXTRACT_AND_RUN: "1"
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
@@ -171,7 +171,7 @@ jobs:
# workflow's own ref: on a tag push that is the tag itself, on a
# manual run it is the branch chosen, so a package format added after
# a release can be built for it.
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: workflow-ref
@@ -210,7 +210,7 @@ jobs:
cd target/packager
sha256sum *.deb *.rpm *.AppImage > SHA256SUMS-linux.txt
cat SHA256SUMS-linux.txt
- - uses: actions/upload-artifact@v7
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: linux
path: |
@@ -220,22 +220,89 @@ jobs:
target/packager/SHA256SUMS-linux.txt
if-no-files-found: error
+ macos:
+ name: macOS disk image
+ needs: version
+ if: ${{ !inputs.linux_only }}
+ runs-on: macos-latest
+ timeout-minutes: 90
+ env:
+ # The oldest macOS the binary and the bundle's Info.plist accept.
+ MACOSX_DEPLOYMENT_TARGET: "11.0"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ persist-credentials: false
+ # The bundle definition comes from the workflow's own ref, as the
+ # Linux packaging does, so a manual run can attach it to an older tag.
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.sha }}
+ path: workflow-ref
+ sparse-checkout: |
+ packaging
+ assets
+ persist-credentials: false
+ - run: |
+ cp -r workflow-ref/packaging/. packaging/
+ cp workflow-ref/assets/icon.icns assets/icon.icns
+ - run: rustup show active-toolchain
+ - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
+ - run: cargo test --locked
+ # One binary for Apple silicon and Intel Macs.
+ - name: Universal binary
+ run: |
+ cargo build --release --locked --target aarch64-apple-darwin
+ cargo build --release --locked --target x86_64-apple-darwin
+ mkdir -p target/universal
+ lipo -create -output target/universal/waveflow-quickplay \
+ target/aarch64-apple-darwin/release/waveflow-quickplay \
+ target/x86_64-apple-darwin/release/waveflow-quickplay
+ lipo -info target/universal/waveflow-quickplay
+ # Signed ad hoc, as WaveFlow Desktop's macOS build is: sealed, with no
+ # Apple Developer ID behind it and an identity that changes with every
+ # build, so Gatekeeper blocks the first launch of each downloaded
+ # version until the user allows it (see packaging/macos/bundle.sh).
+ - name: App bundle and disk image
+ run: |
+ version=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
+ mkdir -p target/packager
+ bash packaging/macos/bundle.sh target/universal/waveflow-quickplay "$version" target/packager
+ app="target/packager/WaveFlow QuickPlay.app"
+ codesign --display --verbose=2 "$app" 2>&1 | grep -q 'Signature=adhoc'
+ lipo -info "$app/Contents/MacOS/waveflow-quickplay" | grep -q 'x86_64 arm64'
+ test "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")" = "$version"
+ - name: Checksums
+ run: |
+ cd target/packager
+ shasum -a 256 *.dmg > SHA256SUMS-macos.txt
+ cat SHA256SUMS-macos.txt
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: macos
+ path: |
+ target/packager/*.dmg
+ target/packager/SHA256SUMS-macos.txt
+ if-no-files-found: error
+
publish:
name: GitHub release
- needs: [windows, linux]
- # Runs when every job that ran succeeded; `windows` may have been
- # skipped by `linux_only`.
- if: ${{ always() && needs.linux.result == 'success' && (needs.windows.result == 'success' || needs.windows.result == 'skipped') }}
+ needs: [windows, linux, macos]
+ # Runs when every job that ran succeeded; `windows` and `macos` may
+ # have been skipped by `linux_only`.
+ if: ${{ always() && needs.linux.result == 'success' && (needs.windows.result == 'success' || needs.windows.result == 'skipped') && (needs.macos.result == 'success' || needs.macos.result == 'skipped') }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.RELEASE_TAG }}
persist-credentials: false
- - uses: actions/download-artifact@v8
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
path: dist
merge-multiple: true
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 04ecb2d..9e397f0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,8 +2,8 @@
Thanks for wanting to help. QuickPlay is the small player next to
[WaveFlow](https://github.com/InstaZDLL/WaveFlow): **Rust + egui/eframe** on
-top of `waveflow-core`, the desktop app's codec and tag crate. WaveFlow's
-[CONTRIBUTING.md](https://github.com/InstaZDLL/WaveFlow/blob/main/CONTRIBUTING.md)
+top of `waveflow-core`, the desktop app's codec, tag and playback-decoding
+crate. WaveFlow's [CONTRIBUTING.md](https://github.com/InstaZDLL/WaveFlow/blob/main/CONTRIBUTING.md)
is the canonical guide for the WaveFlow family; this file adds what is
specific to QuickPlay.
@@ -42,6 +42,9 @@ cargo build --config 'patch."https://github.com/InstaZDLL/WaveFlow".waveflow-cor
`QUICKPLAY_CAPTURE_AFTER_MS`) writes a PNG of the player window after
start, which is how the window is checked on displays that cannot be
grabbed from outside (GNOME on Wayland, an ssh session on macOS).
+`QUICKPLAY_WAVEFLOW=` points the "Continue in WaveFlow" button at
+another WaveFlow executable, such as a development build, and skips the
+version check that otherwise hides it for WaveFlow before 2.0.0-beta.1.
## Before you open a PR
@@ -93,7 +96,9 @@ test checks the placeholders.
Version in `Cargo.toml`, notes in `docs/releases/v.md`, then a
`v` tag: `.github/workflows/release.yml` builds the signed Windows
-installer, the `.deb` and the AppImage and publishes the GitHub release.
+installer, the `.deb`, the `.rpm`, the AppImage and the macOS disk image
+(a universal app signed ad hoc, `packaging/macos/bundle.sh`) and publishes
+the GitHub release.
Only the maintainers tag.
## Reporting bugs and security issues
diff --git a/Cargo.lock b/Cargo.lock
index 8797ddb..cc84b4a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4776,7 +4776,7 @@ dependencies = [
[[package]]
name = "waveflow-quickplay"
-version = "1.0.0"
+version = "1.1.0"
dependencies = [
"cpal",
"crossbeam-channel",
@@ -4786,6 +4786,8 @@ dependencies = [
"image",
"interprocess",
"lofty",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
"realfft",
"rfd",
"rtrb",
@@ -4796,6 +4798,7 @@ dependencies = [
"tracing-subscriber",
"waveflow-core",
"windows-sys 0.61.2",
+ "winit",
"winresource",
]
diff --git a/Cargo.toml b/Cargo.toml
index 7572ca2..32d9ba4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "waveflow-quickplay"
-version = "1.0.0"
+version = "1.1.0"
description = "WaveFlow QuickPlay — a standalone, lightweight egui/eframe audio player built on WaveFlow's Rust audio infrastructure."
authors = ["InstaZDLL "]
edition = "2021"
@@ -103,6 +103,20 @@ windows-sys = { version = "0.61", features = [
"Win32_Storage_FileSystem",
] }
+[target.'cfg(target_os = "macos")'.dependencies]
+# Files opened from Finder arrive as `application:openURLs:` on the
+# application delegate (src/macos.rs): the event loop is built here so the
+# method can be added to winit's delegate before it starts, with the winit
+# and objc2 versions eframe already pulls.
+winit = { version = "0.30", default-features = false }
+objc2 = "0.5"
+objc2-foundation = { version = "0.2", features = [
+ "NSArray",
+ "NSEnumerator",
+ "NSString",
+ "NSURL",
+] }
+
[build-dependencies]
# Embeds the icon + VERSIONINFO into the Windows executable only; a no-op
# elsewhere (see build.rs).
diff --git a/README.md b/README.md
index a3d8420..68b8523 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,12 @@
Double-click. Just listen.
- A small, fast, native music player for Windows and Linux — powered by WaveFlow.
+ A small, fast, native music player for Windows, Linux and macOS — powered by WaveFlow.
-
-
+
+
@@ -27,7 +27,7 @@
-
+
@@ -56,10 +56,11 @@
Download the package for your system from the [latest release](https://github.com/InstaZDLL/waveflow-quickplay/releases/latest):
-- **Windows**: `waveflow-quickplay__x64-setup.exe`. A per-user installer, no administrator rights, that also adds *Play with WaveFlow QuickPlay* and *Add to WaveFlow QuickPlay playlist* to the right-click menu of audio files and folders, and removes them on uninstall. It is signed with WaveFlow's self-signed certificate, so SmartScreen may warn once.
+- **Windows**: `waveflow-quickplay__x64-setup.exe`. A per-user installer, no administrator rights, that also adds *Play with QuickPlay* and *Add to QuickPlay playlist* to the right-click menu of audio files and folders, and removes them on uninstall. It is signed with WaveFlow's self-signed certificate, so SmartScreen may warn once.
- **Linux**: a `.deb` for Debian / Ubuntu, an `.rpm` for Fedora / RHEL / openSUSE, or an `.AppImage` for any distribution (make it executable and run it). All three come with the desktop entry, so QuickPlay appears in "Open with" for audio files.
+- **macOS**: `waveflow-quickplay__macos-universal.dmg`, one app for Apple silicon and Intel Macs, macOS 11 or later. Open it and drag *WaveFlow QuickPlay* to Applications. The app is signed ad hoc, not by an Apple Developer ID, so macOS blocks the first launch: open System Settings › Privacy & Security and choose *Open Anyway* (on macOS 14 and earlier, right-click the app and choose *Open*). This is asked on the first launch of each version you download: every release is signed anew.
-Then double-click a track, or right-click it. That is the whole setup.
+Then double-click a track, or right-click it. That is the whole setup. On macOS, right-click a track and choose *Open With › WaveFlow QuickPlay*.
From the command line
@@ -73,7 +74,7 @@ waveflow-quickplay --register # file-manager integration, curren
waveflow-quickplay --unregister
```
-A hand-copied binary (see [Build and test](#build-and-test)) needs `--register` once, and again if it moves: the registry entries and the `.desktop` file point at the executable's path. The installer does it for you.
+On Windows and Linux, a hand-copied binary (see [Build and test](#build-and-test)) needs `--register` once, and again if it moves: the registry entries and the `.desktop` file point at the executable's path. The installer does it for you.
@@ -103,7 +104,7 @@ They are complementary, not two sizes of the same thing: QuickPlay deliberately
- **Instant pause, clean seeks**, no clicks or pops: the audio thread never waits on the interface.
- **Auto-advance** with the track's tail played out; broken files are skipped and flagged in the playlist.
- **Spectrum** timed to the output clock, so the bars follow what you hear.
-- **Continue in WaveFlow**: when WaveFlow Desktop 2.0 or later is installed, the button with its logo hands the queue, the track, the position and the play or pause state over to it, and QuickPlay closes. WaveFlow picks up at the same spot, from its library when the files are in it.
+- **Continue in WaveFlow**: when WaveFlow Desktop 2.0.0-beta.1 or later is installed, the button with its logo hands the queue, the track, the position and the play or pause state over to it, and QuickPlay closes. WaveFlow picks up at the same spot, from its library when the files are in it.
### 🖼️ Two sizes, one window
@@ -127,8 +128,9 @@ They are complementary, not two sizes of the same thing: QuickPlay deliberately
### 🖱️ From the file manager
- **One instance**: a second launch hands its files to the open player and exits. Plain launch replaces the queue and plays, `--enqueue` appends; a multi-selection that Explorer starts as one process per file is merged.
-- `--register` adds *Play with WaveFlow QuickPlay* and *Add to WaveFlow QuickPlay playlist* to the context menu of every audio extension and of folders, plus an "Open with" entry, for the current user and without administrator rights. On Linux it writes a `.desktop` entry with the audio MIME types. `--unregister` removes it all.
+- `--register` adds *Play with QuickPlay* and *Add to QuickPlay playlist* to the context menu of every audio extension and of folders, plus an "Open with" entry, for the current user and without administrator rights. On Linux it writes a `.desktop` entry with the audio MIME types. `--unregister` removes it all.
- The default handler for the extensions is left to you (Settings › Default apps, or `xdg-mime default app.waveflow.quickplay.desktop audio/flac`).
+- **macOS**: the app declares the audio formats it plays, so once it is in Applications Finder lists it under *Open With* for them, a multi-selection included. To make it the default player for a format, select a file, *Get Info › Open with › WaveFlow QuickPlay › Change All*. Finder has no per-app menu entries, so there is no *Add to playlist* there.
### ⚙️ Settings and languages
@@ -203,6 +205,7 @@ cargo install cargo-packager cargo-generate-rpm
cargo packager --release --formats nsis # Windows: per-user installer
cargo packager --release --formats deb,appimage # Linux
cat packaging/rpm.toml >> Cargo.toml && cargo generate-rpm # Linux: .rpm → target/generate-rpm/
+bash packaging/macos/bundle.sh target/release/waveflow-quickplay target/packager # macOS: .app + .dmg
```
The installers come out in `target/packager/`, the `.rpm` in
@@ -214,7 +217,7 @@ desktop entry. Releases are built by `.github/workflows/release.yml` from a
## Architecture
-QuickPlay is Rust + egui, under 8 000 lines including the language tables,
+QuickPlay is Rust + egui, under 9 000 lines including the language tables,
on top of `waveflow-core`, the crate that holds WaveFlow's codec registry
(symphonia + libopus), DSD converter, tag reader and, behind its `playback`
feature, the decoding the desktop plays through: open a track, decode it
@@ -284,10 +287,11 @@ The working set is dominated by the OpenGL driver and the decoded cover
## Known limitations
-- **macOS** runs from the bare binary (checked on macOS 26 / Apple silicon:
- playback, the docked playlist, minimize and restore from the Dock, the
- single instance). There is no `.app` bundle or installer yet, and file
- associations there need one; `--register` says so and does nothing.
+- **macOS** ships as an app in a disk image, signed ad hoc rather than by
+ an Apple Developer ID, so Gatekeeper blocks the first launch of each
+ downloaded version until it is allowed in System Settings › Privacy &
+ Security. Finder offers *Open With* but no *Add to playlist*, which it has
+ no place for; `--register` has nothing to do there and says so.
- **Wayland** does not let an application position its windows, so the
playlist opens where the compositor puts it and does not follow the
player there. Under XWayland and everywhere else it docks.
@@ -316,7 +320,7 @@ part agrees to the [Code of Conduct](CODE_OF_CONDUCT.md).
| --- | --- | --- | --- |
| `assets/fonts/Inter-Regular.ttf`, `Inter-SemiBold.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
| `assets/fonts/NotoSansCJKsc-Regular.otf` | Google, Adobe (Noto CJK) | https://github.com/notofonts/noto-cjk | OFL-1.1 (`assets/fonts/OFL-NotoSansCJK.txt`) |
-| `assets/icon-128.png`, `assets/icon.ico` | WaveFlow | `src-tauri/crates/app/icons/` | GPL-3.0-only (project) |
+| `assets/icon-128.png`, `assets/icon.ico`, `assets/icon.icns` | WaveFlow | `src-tauri/crates/app/icons/` | GPL-3.0-only (project) |
The screenshots show *Feel Special* by TWICE, used for illustration only.
diff --git a/assets/icon.icns b/assets/icon.icns
new file mode 100644
index 0000000..d800bac
Binary files /dev/null and b/assets/icon.icns differ
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 286b32a..dffff6c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -32,8 +32,10 @@ waveflow-quickplay
├── src/waveflow.rs finds WaveFlow, hands the playback over to it (core's `handoff`)
├── src/integration.rs --register / --unregister (registry verbs, .desktop file)
├── src/platform.rs Windows: owner link between the playlist window and the player
+ ├── src/macos.rs macOS: the files Finder opens (application:openURLs:)
├── src/ui/ theme, painted icons, widgets, player pane, playlist window, settings page
- └── packaging/ NSIS template and Linux desktop entry for cargo-packager
+ └── packaging/ NSIS template and Linux desktop entry for cargo-packager,
+ the .rpm definition, the macOS Info.plist and bundle script
```
The UI never touches the engine or the queue directly: every pane reads a
@@ -102,6 +104,9 @@ UI thread (egui) ──AudioCmd (crossbeam)──► decoder thread (std) ─
track, so a slow previous load cannot flip the state of the new one.
- The engine is created lazily on the first load, released on exit, and
released on an output-stream error so the next play reopens the device.
+ An xrun is not one: the stream recovers from it by itself, and macOS
+ reports one whenever the process stalls (the first launch of a new build
+ while it is scanned), so it is logged and playback carries on.
- The spectrum analyser (`src/audio/spectrum.rs`, the desktop's FFT and band
ladder without the Tauri emit) runs on the decoder thread and timestamps
every frame with the output position it belongs to; the UI shows the
@@ -115,10 +120,12 @@ exclusive output, device selection, radio / remote sources, analytics.
The main window has a custom title bar (drag, settings, pin, minimize,
close) and resize edges: `with_decorations(false)` plus
-`ViewportCommand::StartDrag` / `BeginResize`. Compact mode locks the height
-to the controls block; artwork mode adds the cover above it and makes the
-window freely resizable; the settings page takes the content area in both
-modes.
+`ViewportCommand::StartDrag` / `BeginResize`. The bar reads "QuickPlay"
+beside the WaveFlow logo; the OS title stays "WaveFlow QuickPlay", which
+is how the playlist window finds the player on Windows. Compact mode
+locks the height to the controls block; artwork mode adds the cover above
+it and makes the window freely resizable; the settings page takes the
+content area in both modes.
The playlist is a second native window (`show_viewport_immediate`) docked
beside the player, as PotPlayer's playlist is:
@@ -164,6 +171,21 @@ against the "Open with" command, so a development build never redirects
the installed menu). `--unregister` removes everything. The Windows
installer runs both at install and uninstall time.
+On macOS there is nothing to register: the bundle's Info.plist declares
+the audio formats (by extension, ranked Alternate, as WaveFlow Desktop
+does), which puts QuickPlay under Finder's "Open With". Finder does not
+pass the file on the command line; it sends an "open documents" Apple
+event, which AppKit hands to the application delegate as
+`application:openURLs:`. winit 0.30 installs a delegate of its own,
+panics if it is replaced, and does not answer that selector, so
+`src/macos.rs` builds the event loop itself, adds the method to winit's
+`WinitApplicationDelegate` class through the Objective-C runtime before
+the loop runs (a file opened at launch arrives before the first frame),
+and hands the loop to eframe with `create_native`. The method queues the
+paths, one list per open, and wakes the UI. Each open replaces the queue
+as `play` does, without the burst rule (Explorer's one process per file);
+a multi-selection arrives in one piece.
+
## Continuing in WaveFlow
When a WaveFlow that takes handoffs is installed, the player shows its
@@ -209,5 +231,6 @@ each other on Windows.
app write a PNG of the player window after start, for displays that cannot
be grabbed from outside such as GNOME on Wayland or an ssh session on
macOS. `RUST_LOG=debug` turns the log up. The repository's CI runs fmt,
-clippy and the tests on Windows and Ubuntu; releases are built from a `v*`
-tag by `.github/workflows/release.yml`.
+clippy and the tests on Windows, Ubuntu and macOS; releases are built from
+a `v*` tag by `.github/workflows/release.yml`, the macOS disk image by
+`packaging/macos/bundle.sh` around a universal binary, signed ad hoc.
diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md
new file mode 100644
index 0000000..79a87af
--- /dev/null
+++ b/docs/releases/v1.1.0.md
@@ -0,0 +1,46 @@
+
+
+
+
+# WaveFlow QuickPlay 1.1.0
+
+A macOS app, repeat and shuffle in the playlist, a hand-over to WaveFlow, cleaner DSD, and the player now decodes through exactly the same code as WaveFlow Desktop.
+
+## New
+
+- **macOS app**: a disk image with one app for Apple silicon and Intel Macs, macOS 11 or later. Finder offers it under *Open With* for every format it plays, DSF and DFF included, and it can be made the default player from *Get Info*. Until now macOS meant building from source.
+- **Repeat and shuffle** in the playlist toolbar. One press on repeat loops the playlist, a second loops the current track, a third turns it off. Shuffle plays every track once before any comes round again, and Previous walks back through what played. Both are remembered, and `R` and `H` toggle them from the keyboard.
+- **Continue in WaveFlow**: a button with the WaveFlow logo hands the queue, the current track, the position and the play or pause state over to WaveFlow Desktop, then QuickPlay closes. WaveFlow picks up at the same spot. The button appears only when WaveFlow Desktop **2.0.0-beta.1** or later is installed, so it stays hidden until that version is out.
+- **Long lyric lines keep up with the song**: a line wider than its column holds for a moment, then scrolls so that its end is on screen before the next line starts.
+- **A shorter title bar** that reads *QuickPlay* beside the WaveFlow logo, which leaves more room for the track's title. The file-manager entries are shortened the same way, to *Play with QuickPlay* and *Add to QuickPlay playlist*, in all eight languages.
+
+## Fixed
+
+- **DSD no longer hisses.** DSF and DFF files are converted with WaveFlow Desktop's 256-tap filter instead of 64 taps, which let the DSD shaping noise through as a steady hiss in the background.
+- **Explorer menu order**: *Play with QuickPlay* now comes above *Add to QuickPlay playlist*. The installer removes the old entry.
+- **A brief audio stall no longer stops the track.** A buffer underrun, such as the one macOS causes while it checks a new app on its first launch, was taken for a lost output device: the engine was released and the track stopped with an error. Playback now carries on.
+- **A slow file no longer freezes the controls.** A track on a network share or a disk that is spinning up opens in the background, and Stop, another track or closing the window answer at once. Files on a network share are read ahead, so a seek there does not wait for the whole transfer.
+
+## Under the hood
+
+QuickPlay now plays through `waveflow-core`'s playback module, the decoding WaveFlow Desktop itself uses, instead of a copy of it. A decoding fix made for the desktop reaches QuickPlay with the next core update. What QuickPlay keeps of its own is the small transport around it: the decoder thread, the lock-free ring and the audio callback.
+
+## Install
+
+- **Windows**: download `waveflow-quickplay_1.1.0_x64-setup.exe`. It installs for the current user without administrator rights and registers the context-menu entries. Run it over 1.0.0 to upgrade; your settings live in their own file and are kept.
+- **Linux**: the `.deb` for Debian and Ubuntu, the `.rpm` for Fedora, RHEL and openSUSE, or the `.AppImage` for any distribution (make it executable and run it). All three carry the desktop entry, so QuickPlay appears in "Open with" for audio files.
+- **macOS**: download `waveflow-quickplay_1.1.0_macos-universal.dmg`, open it and drag *WaveFlow QuickPlay* to Applications. The app is signed ad hoc, not by an Apple Developer ID, so macOS blocks the first launch: open System Settings › Privacy & Security and choose *Open Anyway* (on macOS 14 and earlier, right-click the app and choose *Open*). This is asked again for each new version you download, since every release is signed anew.
+
+SHA-256 checksums are in `SHA256SUMS-windows.txt`, `SHA256SUMS-linux.txt` and `SHA256SUMS-macos.txt`.
+
+> [!NOTE]
+> The Windows installer is signed with WaveFlow's self-signed certificate, so Windows may still show a SmartScreen warning on first launch.
+
+## Known limitations
+
+- On macOS, Finder has no per-app menu entries, so there is no *Add to QuickPlay playlist* there; *Open With* replaces the queue, as *Play with* does on Windows.
+- **Continue in WaveFlow** needs WaveFlow Desktop 2.0.0-beta.1 or later, read from the install's metadata. The button stays hidden for an older WaveFlow, for one whose version cannot be read (such as an AppImage put on `PATH` by hand), and for the Flatpak build, whose sandbox cannot see the hand-over file.
+- On Wayland the playlist window opens where the compositor places it and does not follow the player; the protocol does not let an application position its windows.
+- The output device is the system default, opened in shared mode.
+
+**Full changelog**: https://github.com/InstaZDLL/waveflow-quickplay/compare/v1.0.0...v1.1.0
diff --git a/docs/screenshots/player-and-playlist.png b/docs/screenshots/player-and-playlist.png
index d66bede..5998b72 100644
Binary files a/docs/screenshots/player-and-playlist.png and b/docs/screenshots/player-and-playlist.png differ
diff --git a/docs/screenshots/player-compact.png b/docs/screenshots/player-compact.png
index 8f702e4..c6950bf 100644
Binary files a/docs/screenshots/player-compact.png and b/docs/screenshots/player-compact.png differ
diff --git a/docs/screenshots/player-expanded.png b/docs/screenshots/player-expanded.png
index 2022c23..ebd2afd 100644
Binary files a/docs/screenshots/player-expanded.png and b/docs/screenshots/player-expanded.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index 6916bdb..ad82c65 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/packaging/app.waveflow.quickplay.desktop b/packaging/app.waveflow.quickplay.desktop
index 582404f..69584cc 100644
--- a/packaging/app.waveflow.quickplay.desktop
+++ b/packaging/app.waveflow.quickplay.desktop
@@ -18,12 +18,12 @@ StartupWMClass=app.waveflow.quickplay
Actions=Enqueue;
[Desktop Action Enqueue]
-Name=Add to WaveFlow QuickPlay playlist
-Name[fr]=Ajouter à la liste de lecture de WaveFlow QuickPlay
-Name[de]=Zur WaveFlow QuickPlay-Wiedergabeliste hinzufügen
-Name[es]=Añadir a la lista de WaveFlow QuickPlay
-Name[it]=Aggiungi alla playlist di WaveFlow QuickPlay
-Name[zh_CN]=添加到 WaveFlow QuickPlay 播放列表
-Name[ja]=WaveFlow QuickPlay のプレイリストに追加
-Name[ko]=WaveFlow QuickPlay 재생목록에 추가
+Name=Add to QuickPlay playlist
+Name[fr]=Ajouter à la liste de lecture de QuickPlay
+Name[de]=Zur QuickPlay-Wiedergabeliste hinzufügen
+Name[es]=Añadir a la lista de QuickPlay
+Name[it]=Aggiungi alla playlist di QuickPlay
+Name[zh_CN]=添加到 QuickPlay 播放列表
+Name[ja]=QuickPlay のプレイリストに追加
+Name[ko]=QuickPlay 재생목록에 추가
Exec=waveflow-quickplay --enqueue %F
diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist
new file mode 100644
index 0000000..0bb6fab
--- /dev/null
+++ b/packaging/macos/Info.plist
@@ -0,0 +1,68 @@
+
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Audio file
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ mp3
+ flac
+ wav
+ aiff
+ aif
+ ogg
+ oga
+ opus
+ m4a
+ aac
+ dsf
+ dff
+
+
+
+ CFBundleDisplayName
+ WaveFlow QuickPlay
+ CFBundleName
+ WaveFlow QuickPlay
+ CFBundleExecutable
+ waveflow-quickplay
+ CFBundleIconFile
+ icon.icns
+ CFBundleIdentifier
+ app.waveflow.quickplay
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ @VERSION@
+ CFBundleVersion
+ @VERSION@
+ LSApplicationCategoryType
+ public.app-category.music
+ LSMinimumSystemVersion
+ 11.0
+ NSHighResolutionCapable
+
+ NSHumanReadableCopyright
+ Copyright © 2026 InstaZDLL. GPL-3.0-only.
+
+
diff --git a/packaging/macos/bundle.sh b/packaging/macos/bundle.sh
new file mode 100755
index 0000000..9976d22
--- /dev/null
+++ b/packaging/macos/bundle.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Builds "WaveFlow QuickPlay.app" around a built binary, signs it ad hoc
+# and wraps it in a .dmg with a link to /Applications.
+#
+# packaging/macos/bundle.sh
+#
+# Ad-hoc signing ("-") is the macOS counterpart of the self-signed
+# Windows certificate: the bundle is sealed (tampering breaks it), but no
+# Apple Developer ID vouches for it. Its identity is the hash of the code
+# it signs, so every build has a new one. Gatekeeper blocks the first
+# launch of a downloaded copy: on macOS 15 Sequoia and later the user
+# allows it in System Settings › Privacy & Security (Open Anyway), on
+# earlier versions with right-click › Open. Apple silicon refuses to run
+# unsigned code at all, so the signature is not optional.
+set -euo pipefail
+
+binary=$1
+version=$2
+out=$3
+here=$(cd "$(dirname "$0")" && pwd)
+root=$(cd "$here/../.." && pwd)
+
+app="$out/WaveFlow QuickPlay.app"
+rm -rf "$app"
+mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+cp "$binary" "$app/Contents/MacOS/waveflow-quickplay"
+chmod 755 "$app/Contents/MacOS/waveflow-quickplay"
+cp "$root/assets/icon.icns" "$app/Contents/Resources/icon.icns"
+cp "$root/LICENSE" "$app/Contents/Resources/LICENSE.txt"
+sed "s/@VERSION@/$version/g" "$here/Info.plist" > "$app/Contents/Info.plist"
+plutil -lint "$app/Contents/Info.plist"
+
+codesign --force --sign - --timestamp=none "$app"
+codesign --verify --strict --verbose=2 "$app"
+codesign --display --verbose=2 "$app" 2>&1 | grep -E '^(Identifier|Format|Signature)='
+
+stage="$out/dmg-stage"
+rm -rf "$stage"
+mkdir -p "$stage"
+cp -R "$app" "$stage/"
+ln -s /Applications "$stage/Applications"
+dmg="$out/waveflow-quickplay_${version}_macos-universal.dmg"
+rm -f "$dmg"
+hdiutil create -volname "WaveFlow QuickPlay $version" -srcfolder "$stage" \
+ -fs HFS+ -format UDZO -ov "$dmg"
+rm -rf "$stage"
+hdiutil verify "$dmg"
+echo "$dmg"
diff --git a/packaging/quickplay.desktop b/packaging/quickplay.desktop
index ac7a7aa..d2c03c9 100644
--- a/packaging/quickplay.desktop
+++ b/packaging/quickplay.desktop
@@ -18,12 +18,12 @@ StartupWMClass=app.waveflow.quickplay
Actions=Enqueue;
[Desktop Action Enqueue]
-Name=Add to WaveFlow QuickPlay playlist
-Name[fr]=Ajouter à la liste de lecture de WaveFlow QuickPlay
-Name[de]=Zur WaveFlow QuickPlay-Wiedergabeliste hinzufügen
-Name[es]=Añadir a la lista de WaveFlow QuickPlay
-Name[it]=Aggiungi alla playlist di WaveFlow QuickPlay
-Name[zh_CN]=添加到 WaveFlow QuickPlay 播放列表
-Name[ja]=WaveFlow QuickPlay のプレイリストに追加
-Name[ko]=WaveFlow QuickPlay 재생목록에 추가
+Name=Add to QuickPlay playlist
+Name[fr]=Ajouter à la liste de lecture de QuickPlay
+Name[de]=Zur QuickPlay-Wiedergabeliste hinzufügen
+Name[es]=Añadir a la lista de QuickPlay
+Name[it]=Aggiungi alla playlist di QuickPlay
+Name[zh_CN]=添加到 QuickPlay 播放列表
+Name[ja]=QuickPlay のプレイリストに追加
+Name[ko]=QuickPlay 재생목록에 추가
Exec={{exec}} --enqueue %F
diff --git a/src/app.rs b/src/app.rs
index 16ad64a..046a2c0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -117,6 +117,9 @@ pub struct QuickPlayApp {
/// Where the pending load starts. Until its `Loaded` arrives, the
/// engine's clock still counts the previous track.
load_start_ms: u64,
+ /// Bumped by every `clear`: a folder scan requested before it belongs to
+ /// a queue that is gone, and its result is dropped when it arrives.
+ scan_generation: u64,
/// Synced lyrics of the current track, from its `.lrc` sidecar.
lyrics: Option,
/// Test-only window capture, armed by `QUICKPLAY_CAPTURE_DIR`.
@@ -132,6 +135,8 @@ pub struct QuickPlayApp {
/// The last frame taken, which the bars ease toward.
spectrum_target: Vec,
spectrum_tick: Instant,
+ /// Xruns already logged; the output only counts them.
+ xruns_logged: u64,
/// WaveFlow's executable, looked for in the background: the player
/// offers to carry on there when it is installed.
waveflow: waveflow::Finder,
@@ -149,6 +154,8 @@ impl QuickPlayApp {
if let Some(server) = &ipc {
server.attach(&ctx);
}
+ #[cfg(target_os = "macos")]
+ crate::macos::set_repaint(&ctx);
let notify_ctx = ctx.clone();
let notify: Arc = Arc::new(move || notify_ctx.request_repaint());
let lang = Lang::resolve(&settings.language);
@@ -187,6 +194,7 @@ impl QuickPlayApp {
lyrics: None,
loaded_generation: 0,
load_start_ms: 0,
+ scan_generation: 0,
capture: Capture::from_env(),
ipc,
// Files given on the command line count as the first `play` of a
@@ -196,6 +204,7 @@ impl QuickPlayApp {
spectrum: vec![0.0; crate::audio::spectrum::BAND_COUNT],
spectrum_target: vec![0.0; crate::audio::spectrum::BAND_COUNT],
spectrum_tick: Instant::now(),
+ xruns_logged: 0,
waveflow: waveflow::Finder::spawn({
let ctx = cc.egui_ctx.clone();
move || ctx.request_repaint()
@@ -222,6 +231,8 @@ impl QuickPlayApp {
std::sync::atomic::Ordering::Relaxed,
);
tracing::info!(device = ?engine.device_name(), "audio engine started");
+ // A new engine counts its xruns from zero.
+ self.xruns_logged = 0;
self.engine = Some(engine);
}
Err(err) => {
@@ -313,7 +324,8 @@ impl QuickPlayApp {
// Folders are walked on the loader's thread; the result comes
// back as `LoaderEvent::Scanned`.
self.set_status(self.t().status_scanning);
- self.loader.request_scan(inputs.to_vec(), autoplay);
+ self.loader
+ .request_scan(inputs.to_vec(), autoplay, self.scan_generation);
return;
}
let paths = library::collect_audio_paths(inputs);
@@ -491,6 +503,7 @@ impl QuickPlayApp {
fn clear(&mut self) {
self.queue.clear();
+ self.scan_generation += 1;
self.selected = None;
self.playing_id = None;
self.duration_ms = None;
@@ -516,6 +529,16 @@ impl QuickPlayApp {
// ── Events ──────────────────────────────────────────────────────────
fn pump_engine_events(&mut self) {
+ if let Some(engine) = &self.engine {
+ let xruns = engine
+ .shared()
+ .xruns
+ .load(std::sync::atomic::Ordering::Relaxed);
+ if xruns > self.xruns_logged {
+ tracing::debug!(total = xruns, "audio xrun, the stream carried on");
+ }
+ self.xruns_logged = xruns;
+ }
let events = match &self.engine {
Some(engine) => engine.poll_events(),
None => return,
@@ -601,7 +624,15 @@ impl QuickPlayApp {
fn pump_loader_events(&mut self) {
for event in self.loader.poll() {
match event {
- LoaderEvent::Scanned { paths, autoplay } => {
+ LoaderEvent::Scanned {
+ paths,
+ autoplay,
+ generation,
+ } => {
+ // Requested for a queue that has been cleared since.
+ if generation != self.scan_generation {
+ continue;
+ }
self.status = None;
self.add_resolved(paths, true, autoplay);
}
@@ -1086,6 +1117,15 @@ impl QuickPlayApp {
// double-clicking two files is slower than this.
const BURST: Duration = Duration::from_millis(500);
let mut got_any = false;
+ // Each Finder open is one request, a multi-selection included: it
+ // replaces the queue like `play`, without the burst rule, which is
+ // for Explorer starting one process per selected file.
+ #[cfg(target_os = "macos")]
+ for paths in crate::macos::take_opened() {
+ got_any = true;
+ self.clear();
+ self.add_paths(&paths, true);
+ }
while let Some(msg) = self.ipc.as_ref().and_then(|s| s.try_recv()) {
got_any = true;
match msg.mode {
diff --git a/src/audio/output.rs b/src/audio/output.rs
index 24a857a..4d9e7cb 100644
--- a/src/audio/output.rs
+++ b/src/audio/output.rs
@@ -166,7 +166,19 @@ fn open_stream(
where
T: cpal::SizedSample + cpal::FromSample + Send + 'static,
{
- let err_fn = move |err: cpal::Error| on_error(format!("audio device error: {err}"));
+ let counters = Arc::clone(&shared);
+ let err_fn = move |err: cpal::Error| {
+ // An xrun is a glitch the stream recovers from by itself: CoreAudio
+ // reports one whenever the process stalls, as on the first launch of
+ // a new build while macOS scans it. Releasing the engine for it would
+ // stop the track; only the other errors mean the stream is gone.
+ // Counted, not logged: on ALSA this runs on the stream's thread.
+ if err.kind() == cpal::ErrorKind::Xrun {
+ counters.xruns.fetch_add(1, Ordering::Relaxed);
+ return;
+ }
+ on_error(format!("audio device error: {err}"));
+ };
device
.build_output_stream(
*config,
diff --git a/src/audio/state.rs b/src/audio/state.rs
index 0b231ee..d6f9c13 100644
--- a/src/audio/state.rs
+++ b/src/audio/state.rs
@@ -49,6 +49,10 @@ pub struct SharedPlayback {
/// Analysed frames waiting for their play position. Decoder and UI
/// threads only.
pub spectrum: Mutex,
+ /// Buffer underruns and overruns the backend reported. The error
+ /// callback only counts them (on ALSA it runs on the stream's own
+ /// thread, where nothing may block); the UI thread logs the count.
+ pub xruns: AtomicU64,
}
impl SharedPlayback {
@@ -65,6 +69,7 @@ impl SharedPlayback {
samples_pushed: AtomicU64::new(0),
spectrum_enabled: AtomicBool::new(true),
spectrum: Mutex::new(SpectrumQueue::default()),
+ xruns: AtomicU64::new(0),
}
}
diff --git a/src/i18n/de.rs b/src/i18n/de.rs
index 172f27e..7c04c77 100644
--- a/src/i18n/de.rs
+++ b/src/i18n/de.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "Quellcode und Releases",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "Mit WaveFlow QuickPlay wiedergeben",
- action_add_to_playlist: "Zur WaveFlow QuickPlay-Wiedergabeliste hinzufügen",
+ action_play_with: "Mit QuickPlay wiedergeben",
+ action_add_to_playlist: "Zur QuickPlay-Wiedergabeliste hinzufügen",
desktop_comment: "Audiodateien schnell wiedergeben",
};
diff --git a/src/i18n/en.rs b/src/i18n/en.rs
index 78f42f9..e9751b0 100644
--- a/src/i18n/en.rs
+++ b/src/i18n/en.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "Source code and releases",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "Play with WaveFlow QuickPlay",
- action_add_to_playlist: "Add to WaveFlow QuickPlay playlist",
+ action_play_with: "Play with QuickPlay",
+ action_add_to_playlist: "Add to QuickPlay playlist",
desktop_comment: "Play audio files quickly",
};
diff --git a/src/i18n/es.rs b/src/i18n/es.rs
index 4d5b713..a434e15 100644
--- a/src/i18n/es.rs
+++ b/src/i18n/es.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "Código fuente y versiones",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "Reproducir con WaveFlow QuickPlay",
- action_add_to_playlist: "Añadir a la lista de WaveFlow QuickPlay",
+ action_play_with: "Reproducir con QuickPlay",
+ action_add_to_playlist: "Añadir a la lista de QuickPlay",
desktop_comment: "Reproduce archivos de audio al instante",
};
diff --git a/src/i18n/fr.rs b/src/i18n/fr.rs
index b351e01..2778a7d 100644
--- a/src/i18n/fr.rs
+++ b/src/i18n/fr.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "Code source et versions",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "Lire avec WaveFlow QuickPlay",
- action_add_to_playlist: "Ajouter à la liste de lecture de WaveFlow QuickPlay",
+ action_play_with: "Lire avec QuickPlay",
+ action_add_to_playlist: "Ajouter à la liste de lecture de QuickPlay",
desktop_comment: "Lire rapidement des fichiers audio",
};
diff --git a/src/i18n/it.rs b/src/i18n/it.rs
index c2b3f15..d68f006 100644
--- a/src/i18n/it.rs
+++ b/src/i18n/it.rs
@@ -67,7 +67,7 @@ pub const STRINGS: Strings = Strings {
about_source: "Codice sorgente e versioni",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "Riproduci con WaveFlow QuickPlay",
- action_add_to_playlist: "Aggiungi alla playlist di WaveFlow QuickPlay",
+ action_play_with: "Riproduci con QuickPlay",
+ action_add_to_playlist: "Aggiungi alla playlist di QuickPlay",
desktop_comment: "Riproduci rapidamente i file audio",
};
diff --git a/src/i18n/ja.rs b/src/i18n/ja.rs
index 3edee5d..6729c29 100644
--- a/src/i18n/ja.rs
+++ b/src/i18n/ja.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "ソースコードとリリース",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "WaveFlow QuickPlay で再生",
- action_add_to_playlist: "WaveFlow QuickPlay のプレイリストに追加",
+ action_play_with: "QuickPlay で再生",
+ action_add_to_playlist: "QuickPlay のプレイリストに追加",
desktop_comment: "オーディオファイルをすばやく再生",
};
diff --git a/src/i18n/ko.rs b/src/i18n/ko.rs
index 3a73c9f..c6a616b 100644
--- a/src/i18n/ko.rs
+++ b/src/i18n/ko.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "소스 코드 및 릴리스",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "WaveFlow QuickPlay로 재생",
- action_add_to_playlist: "WaveFlow QuickPlay 재생목록에 추가",
+ action_play_with: "QuickPlay로 재생",
+ action_add_to_playlist: "QuickPlay 재생목록에 추가",
desktop_comment: "오디오 파일을 빠르게 재생합니다",
};
diff --git a/src/i18n/zh_cn.rs b/src/i18n/zh_cn.rs
index aa8d181..e943860 100644
--- a/src/i18n/zh_cn.rs
+++ b/src/i18n/zh_cn.rs
@@ -66,7 +66,7 @@ pub const STRINGS: Strings = Strings {
about_source: "源代码与发布版本",
// ── File-manager integration ───────────────────────────────────────
- action_play_with: "使用 WaveFlow QuickPlay 播放",
- action_add_to_playlist: "添加到 WaveFlow QuickPlay 播放列表",
+ action_play_with: "使用 QuickPlay 播放",
+ action_add_to_playlist: "添加到 QuickPlay 播放列表",
desktop_comment: "快速播放音频文件",
};
diff --git a/src/integration.rs b/src/integration.rs
index ee7dc0a..979bcbc 100644
--- a/src/integration.rs
+++ b/src/integration.rs
@@ -8,8 +8,9 @@
//! choice in Settings › Default apps.
//! - Linux: a `.desktop` file with the audio MIME types and an "Add to
//! playlist" action, plus the icon, under `~/.local/share`.
-//! - macOS: file associations need an app bundle; not done from a bare
-//! binary.
+//! - macOS: nothing to write. The app bundle declares the audio types in
+//! its Info.plist, so Finder offers it under "Open With" once it is in
+//! Applications, and the files arrive through src/macos.rs.
//!
//! Both directions are idempotent and print what they did.
@@ -290,7 +291,7 @@ mod platform {
std::path::Path::new(r"C:\Program Files\QP\waveflow-quickplay.exe"),
Lang::Fr.strings(),
);
- assert!(s.contains("@=\"Lire avec WaveFlow QuickPlay\""));
+ assert!(s.contains("@=\"Lire avec QuickPlay\""));
assert!(s.starts_with("Windows Registry Editor Version 5.00\r\n"));
assert!(s.contains(r#"@="\"C:\\Program Files\\QP\\waveflow-quickplay.exe\" \"%1\"""#));
assert!(s.contains(
@@ -434,12 +435,27 @@ mod platform {
false
}
+ #[cfg(target_os = "macos")]
+ pub fn register(_exe: &std::path::Path, _t: &Strings) -> io::Result {
+ Ok("nothing to register on macOS: the app declares the audio formats itself, so Finder offers it under Open With once WaveFlow QuickPlay is in Applications.".into())
+ }
+
+ #[cfg(target_os = "macos")]
+ pub fn unregister() -> io::Result {
+ Ok(
+ "nothing to remove on macOS: delete the app from Applications and Finder forgets it."
+ .into(),
+ )
+ }
+
+ #[cfg(not(target_os = "macos"))]
pub fn register(_exe: &std::path::Path, _t: &Strings) -> io::Result {
Err(io::Error::other(
- "file associations on this platform need an application bundle; not available from a bare binary.",
+ "file-manager integration is not available on this platform.",
))
}
+ #[cfg(not(target_os = "macos"))]
pub fn unregister() -> io::Result {
Err(io::Error::other("nothing to remove on this platform."))
}
diff --git a/src/library.rs b/src/library.rs
index 19aef75..d6fd1c7 100644
--- a/src/library.rs
+++ b/src/library.rs
@@ -242,6 +242,8 @@ pub enum LoaderEvent {
Scanned {
paths: Vec,
autoplay: bool,
+ /// The `generation` the scan was requested with, handed back as is.
+ generation: u64,
},
}
@@ -311,13 +313,20 @@ impl Loader {
/// Expands files and folders on a thread of its own: a dropped library
/// root can hold tens of thousands of entries, and the stats would
/// freeze the window if done on the UI thread.
- pub fn request_scan(&self, inputs: Vec, autoplay: bool) {
+ /// `generation` comes back with the result, so the caller can tell a
+ /// scan it has since given up on.
+ pub fn request_scan(&self, inputs: Vec, autoplay: bool, generation: u64) {
let (tx, notify) = (self.tx.clone(), self.notify.clone());
let spawned = std::thread::Builder::new()
.name("quickplay-scan".into())
.spawn(move || {
let paths = collect_audio_paths(&inputs);
- if tx.send(LoaderEvent::Scanned { paths, autoplay }).is_ok() {
+ let event = LoaderEvent::Scanned {
+ paths,
+ autoplay,
+ generation,
+ };
+ if tx.send(event).is_ok() {
notify();
}
});
diff --git a/src/macos.rs b/src/macos.rs
new file mode 100644
index 0000000..aad73e0
--- /dev/null
+++ b/src/macos.rs
@@ -0,0 +1,115 @@
+//! macOS: the files Finder opens in QuickPlay.
+//!
+//! Finder does not put a file it opens on the command line, as Explorer
+//! and the Linux desktops do: it starts the app (or finds it running) and
+//! sends it an "open documents" Apple event, which AppKit turns into
+//! `application:openURLs:` on the application delegate. winit 0.30
+//! installs its own delegate and panics if it is replaced, and that
+//! delegate does not answer `application:openURLs:`, so [`run_native`]
+//! builds the event loop itself, adds the method to winit's delegate class
+//! through the Objective-C runtime before the loop starts (a file opened
+//! at launch arrives before the first frame) and hands the loop to eframe.
+//! The method queues the paths; the app takes them on its next frame
+//! ([`take_opened`]) and plays them as a `play` hand-over from a second
+//! launch: the queue is replaced.
+
+use std::path::PathBuf;
+use std::sync::{Mutex, OnceLock};
+
+use objc2::runtime::{AnyClass, AnyObject, Imp, Sel};
+use objc2::{ffi, sel};
+use objc2_foundation::{NSArray, NSURL};
+
+/// The class winit registers for its application delegate.
+const WINIT_DELEGATE: &str = "WinitApplicationDelegate";
+
+/// What Finder opened that the app has not taken yet, one entry per open:
+/// each is a request of its own and replaces the queue, even when two
+/// arrive before the same frame.
+static OPENED: Mutex>> = Mutex::new(Vec::new());
+/// Wakes the app when Finder opens files while it is idle.
+static REPAINT: OnceLock = OnceLock::new();
+
+/// `-[WinitApplicationDelegate application:openURLs:]`. One call per open:
+/// a multi-selection in Finder arrives together. Called by AppKit on the
+/// main thread; it must not unwind, and nothing in it panics.
+unsafe extern "C" fn open_urls(
+ _this: *mut AnyObject,
+ _cmd: Sel,
+ _application: *mut AnyObject,
+ urls: *mut NSArray,
+) {
+ // SAFETY: AppKit passes a valid NSArray for the call's duration.
+ let Some(urls) = (unsafe { urls.as_ref() }) else {
+ return;
+ };
+ let paths: Vec = urls
+ .iter()
+ // SAFETY: plain NSURL getters on a live object.
+ .filter(|url| unsafe { url.isFileURL() })
+ .filter_map(|url| unsafe { url.path() })
+ .map(|path| PathBuf::from(path.to_string()))
+ .collect();
+ tracing::info!(files = paths.len(), "opened from Finder");
+ if paths.is_empty() {
+ return;
+ }
+ OPENED.lock().unwrap_or_else(|e| e.into_inner()).push(paths);
+ if let Some(ctx) = REPAINT.get() {
+ ctx.request_repaint();
+ }
+}
+
+/// Give winit's delegate class an `application:openURLs:`. Must run after
+/// the event loop exists (which registers the class) and before it runs.
+fn answer_open_urls() {
+ let Some(class) = AnyClass::get(WINIT_DELEGATE) else {
+ tracing::warn!("{WINIT_DELEGATE} not found: files opened from Finder will not arrive");
+ return;
+ };
+ type OpenUrls = unsafe extern "C" fn(*mut AnyObject, Sel, *mut AnyObject, *mut NSArray);
+ // SAFETY: an `Imp` is called with the arguments of the selector it is
+ // registered for; `open_urls` takes exactly those of
+ // `application:openURLs:` (self, _cmd, NSApplication *, NSArray *),
+ // and "v@:@@" says so to the runtime.
+ let imp: Imp = unsafe { std::mem::transmute::(open_urls) };
+ let added = unsafe {
+ ffi::class_addMethod(
+ class as *const AnyClass as *mut ffi::objc_class,
+ sel!(application:openURLs:).as_ptr(),
+ Some(imp),
+ c"v@:@@".as_ptr(),
+ )
+ };
+ if added == ffi::NO {
+ tracing::warn!("{WINIT_DELEGATE} already answers application:openURLs:");
+ }
+}
+
+/// `eframe::run_native` with Finder's files delivered. Same options, same
+/// app; the only difference is who builds the event loop.
+pub fn run_native(
+ app_name: &str,
+ options: eframe::NativeOptions,
+ app_creator: eframe::AppCreator<'_>,
+) -> eframe::Result {
+ let event_loop = winit::event_loop::EventLoop::::with_user_event()
+ .build()
+ .map_err(eframe::Error::WinitEventLoop)?;
+ answer_open_urls();
+ let mut eframe_app = eframe::create_native(app_name, options, app_creator, &event_loop);
+ event_loop
+ .run_app(&mut eframe_app)
+ .map_err(eframe::Error::WinitEventLoop)
+}
+
+/// Let Finder wake the app when it opens files while the app is idle.
+pub fn set_repaint(ctx: &egui::Context) {
+ let _ = REPAINT.set(ctx.clone());
+}
+
+/// What Finder opened since the last call, one list of paths per open, in
+/// order.
+pub fn take_opened() -> Vec> {
+ std::mem::take(&mut *OPENED.lock().unwrap_or_else(|e| e.into_inner()))
+}
diff --git a/src/main.rs b/src/main.rs
index 15dc0db..4cf98db 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,6 +21,8 @@ mod integration;
mod ipc;
mod library;
mod lyrics;
+#[cfg(target_os = "macos")]
+mod macos;
mod platform;
mod queue;
mod ui;
@@ -188,7 +190,13 @@ fn main() -> eframe::Result {
files = files.len(),
"starting WaveFlow QuickPlay"
);
- eframe::run_native(
+ // On macOS the event loop is built by `macos::run_native`, which also
+ // receives the files Finder opens.
+ #[cfg(target_os = "macos")]
+ let run_native = macos::run_native;
+ #[cfg(not(target_os = "macos"))]
+ let run_native = eframe::run_native;
+ run_native(
"WaveFlow QuickPlay",
options,
Box::new(move |cc| {