Compare commits

..
69 Commits
Author SHA1 Message Date
arabianq 0d0b5f9f29 change version to 1.15.0 2026-07-26 21:52:51 +03:00
arabianqandClaude Opus 5 425c1df61e docs(cli): stop the group descriptions from going stale
Each of the three groups listed its subcommands by hand, and all three lists had
drifted: `get` advertised a `current-file-path` that does not exist while
omitting tracks, outputs, hotkeys and both new volumes; `set` omitted loop,
output and hotkeys; `action` omitted kill, toggle-loop and the hotkey commands.

Replaced the hand-kept lists with a short description of what each group is for.
Clap already prints the real subcommands underneath, so duplicating them in the
parent was guaranteed to rot.

Also `save-daemon-config`, an action, was described as "Daemon configuration",
which says nothing about what it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:49:40 +03:00
arabianqandClaude Opus 5 2073a410e8 docs: correct the README
Not one of the three CLI examples worked: commands live under `action`, `get` and
`set`, and `status` never existed. Replaced with commands that were actually run
against a live daemon, plus the separate monitoring/mic volumes.

Other corrections:

- Build deps were missing libssl-dev and pkg-config, which CI installs and
  openssl-sys needs — building from source failed by following the README.
- The Flatpak, which the README recommends first, has a single entry point, so
  none of the bare pwsp-* commands apply there. Documented `flatpak run`.
- Global hotkeys are a headline feature but need read access to /dev/input,
  i.e. membership in the `input` group. Nothing said so, and the failure is
  silent.
- AUR packages exist in packages/aur but were not mentioned.
- "without ... complex virtual sinks" was false: PWSP creates a
  support.null-audio-sink node itself. Reworded to say what it actually does.
- "Direct communication with the PipeWire API for the lowest possible latency"
  overstated it. The PipeWire API is used for the graph — the virtual mic,
  device discovery, links — while audio goes through rodio, which is why the
  node shows up as alsa_playback.pwsp-daemon.
- /etc/apt/keyrings does not exist on older Debian/Ubuntu; added mkdir -p.
- DeepWiki was called "our official Wiki"; it is generated from the source
  automatically, so it is now described as such.
- Each language link pointed at the file you were already reading.
- Features listed neither the separate volumes nor the localization.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:49:30 +03:00
arabianq cfa31200d4 docs: update screenshots 2026-07-26 21:36:22 +03:00
389640ee0a feat(gui): let the UI language be chosen manually (#179)
The interface followed the system locale with no way to override it. Settings now
carry a language picker, stored as forced_lang in gui.json, with "System" listed
first so the choice stays undoable.

rust-i18n keeps the current locale in a process-wide atomic and t! reads it on
every lookup, so switching takes effect on the next frame without a restart.

Languages are listed by their own name rather than a translated one, since that
is what a reader looking for their language recognises. A test asserts every
shipped locale has such a name, so adding a translation without naming it fails
rather than silently showing a bare language code.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:24:54 +03:00
arabianq 7b87dc02dd deps: arabianq/rodio.git -> RustAudio/rodio.git 2026-07-26 20:55:26 +03:00
785b835237 feat: separate monitoring and output streams (#178)
* feat: split playback into separate monitoring and mic streams

AudioPlayer held a single rodio stream. WirePlumber auto-connected its node to
the default sink (that is the monitoring path) while PWSP explicitly linked the
very same node into pwsp-virtual-mic. Both paths were fed by one Player, so
Player::set_volume scaled them together and they could not be separated — hence
the report of "volume 2 is fine on the other side but deafening locally".

There are now two independent streams with independent gains:

  mic stream        --ensure_route()--> pwsp-virtual-mic
  monitoring stream --ensure_route()--> selected Audio/Sink (or WirePlumber's
                                        choice when no device is pinned)

PipeWire side:
- DeviceType::Sink, so sinks are discoverable like inputs already were, plus the
  playback_* port mapping that Audio/Sink nodes need (monitor_* stays unmapped:
  linking a stream there would be a feedback loop).
- Link globals are tracked in the registry listener; GetLinks/DestroyGlobal make
  ensure_route() possible. It points a node at one target and prunes every other
  link leaving it, creating before pruning so the node is never left unlinked —
  otherwise WirePlumber's autoconnect would re-attach it behind our back.
  Replaces link_player_to_virtual_mic().

Audio side:
- PlayerPair wraps the two rodio Players of a track so transport controls reach
  both from one place instead of being fanned out at every call site.
- Streams are identified by diffing our own stream nodes before and after opening
  one, not by indexing a sorted list: PipeWire reuses freed node ids, so the
  stream opened second can end up with the lower id. Only nodes that look like
  ours are ever considered — pruning a stranger's node would silence another
  application. Opens are therefore sequential, and node discovery checks the
  graph before its first sleep to stay off the play latency path.
- Streams are dropped once nothing is playing, as before: an open stream keeps
  the audio device busy and stops laptops from suspending. The routing is
  rebuilt on the next play.
- Each track decodes twice, once per path. rodio's Buffered is the only shareable
  source and it cannot seek, which the position slider depends on.
- effective_gain() is the single home of the master * track * multiplier math and
  collapses non-finite or negative gains to silence.

Protocol, config and GUI:
- get/set_monitoring_volume, get/set_mic_volume, get/set_output, get_outputs.
- DaemonConfig gains default_output_name, default_monitoring_volume and
  default_mic_volume.
- Volumes arriving over IPC are validated: hotkeys store raw Request JSON, so a
  bad value can reach the daemon without passing through the CLI.
- The footer carries a monitoring slider and a mic slider, both running to 200%,
  plus an output combo box; with four widgets it no longer fits on one line and
  is laid out as two rows.
- SliderLatch owns the "local value wins while the user is interacting" logic
  every slider needs — the daemon is polled at 60 Hz, so without it a slider
  fights the user mid-drag and snaps back on release. That was previously
  copy-pasted per slider as a value/dragged/ignore-until triple.

Fixes along the way: setting the volume multiplier no longer overwrites the
master volume, and get_volume(Some(id)) reports the track's own volume instead of
the product of all three factors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): add monitoring-volume, mic-volume and output commands

get/set volume keeps its old meaning — without --id it moves both masters at
once, which is the "make everything quieter" shortcut. The new commands address
one path each, and values above 1.0 pass through on purpose: amplifying what
goes into the microphone without deafening yourself is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): move device selection into settings

The footer tried to hold two combo boxes, two sliders and two buttons on one
line. Their labels alone run ~300px, so at the 800px minimum window size the
sliders and buttons were pushed off the edge and simply vanished.

Device pickers are set-once controls and belong next to the theme selector, so
they move to the settings screen. The footer keeps the two volume sliders, which
are the ones worth reaching for mid-call, and now fits at the minimum width.

The right-edge spacer is also clamped at zero: computed negative, it used to
shove the buttons out of view rather than merely crowding them.

Verified with screenshots at 800px and 1200px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): align the footer volume icons and sliders

The speaker icon and its slider sat a couple of pixels above the microphone
pair. A horizontal layout centres each widget against the row height known when
that widget is placed, so a row that grows while being filled leaves whatever
was added first sitting too high.

Every footer element is now allocated the same box height, which makes the
centring independent of placement order, and the row is given that height up
front. Measured from screenshots at 800px: icon ink centres were 776.5 and
780.5, now both 776.5; slider rails were 2.5px apart, now 0.5px, which is
sub-pixel rounding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): let the output device be unpinned again

The output picker listed devices but no way back, so choosing one was a one-way
door. It now offers "system default" as the first entry, which sends set_output
with an empty name; the daemon takes that as "stop pinning".

Unpinning deliberately leaves the existing link alone rather than tearing it
down. WirePlumber's autoconnect only runs when a node first appears, so removing
the link would strand the monitoring stream with no output at all. Streams close
as soon as playback stops, so the next sound opens a fresh node that gets routed
like any other application's.

Picking the default target ourselves was tried and reverted: the global
default.audio.sink is only a fallback, and a per-stream target.node overrides it.
On this machine that meant monitoring would have gone straight to the hardware
sink, bypassing the user's EasyEffects chain that WirePlumber routes it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: trim comments and drop an abstraction that earned nothing

Removes the RouteTarget trait and its two unit-struct implementations. They
existed to let route() resolve a target lazily, but both call sites resolve one
line earlier just as well, so a trait, two types and an impl block collapse into
one parameter.

Narrows should_sync, commit and prune_links_from to private: all three were
public but only ever called from within their own module.

Comment pass: drops the decorative section dividers that this file never had,
and rewrites the ones that leaned on a particular machine's setup or narrated a
past bug. What is left explains things the code cannot: why streams close when
idle, why they are opened one at a time, why a link is created before stale ones
are pruned, why playback_* maps to inputs while monitor_* stays unmapped, and
why the footer row height is pinned up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:36:10 +03:00
arabianq 1616d80ee9 change version to 1.14.0 2026-07-24 23:58:39 +03:00
arabianq d97d4c60d2 cargo update 2026-07-24 23:57:38 +03:00
Tarasov AleksandrandGitHub 0c3d7cbd35 feat(gui): better search with automatic tree expansion, directories scanning and caching 2026-07-24 23:49:10 +03:00
arabianq 6d7abdbf53 change version to 1.13.0 2026-07-24 05:52:27 +03:00
arabianq 41bb035b2c cargo update 2026-07-24 05:43:52 +03:00
arabianq 3873bf4270 deps: update rodio 2026-07-24 05:43:02 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2b60b47cce chore(deps): bump clap from 4.6.3 to 4.6.4 in the clap-stack group (#176)
* chore(deps): bump clap from 4.6.3 to 4.6.4 in the clap-stack group

Bumps the clap-stack group with 1 update: [clap](https://github.com/clap-rs/clap).


Updates `clap` from 4.6.3 to 4.6.4
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.3...clap_complete-v4.6.4)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: clap-stack
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-24 05:42:53 +03:00
Tarasov AleksandrandGitHub 65ae8589db feat(lib, daemon, cli, gui): add volume multiplier (#175)
* initial implementation

* rename DaemonConfig.volume_multiplier to default_volume_multiplier

* add volume_multiplier to the FullState

* add pwsp-gui integration
2026-07-24 05:33:51 +03:00
Tarasov AleksandrandGitHub a33d023950 feat(daemon, cli): global DaemonConfig and an ability to get/save it using cli (#174)
* use one global Arc<DaemonConfig>

* implement commands

* replace Arc<DaemonConfig> with Arc<Mutex<DaemonConfig>> and fix pwsp-gui

* add pwsp-cli support

* replace serde_json::to_string_pretty with to_string

* refactor

* implement UpdateDaemonConfigCommand and use it in pwsp-gui so it now uses real in-memory config, not the saved one

* implement utils::gui get_daemon_config and update_daemon_config to simplify code
2026-07-24 04:49:51 +03:00
arabianq 5c73bc1bea fix cagro sources 2026-07-20 21:26:08 +03:00
arabianq 42e305869a change version to 1.12.5 2026-07-20 21:20:02 +03:00
arabianq cb7f4f7f6b update cargo-sources.json 2026-07-20 21:18:43 +03:00
arabianq 62821904af cargo update 2026-07-20 21:16:56 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Tarasov Aleksandrarabianq
3a218283db chore(deps): bump the serde-stack group with 2 updates (#169)
* chore(deps): bump the serde-stack group with 2 updates

Bumps the serde-stack group with 2 updates: [serde](https://github.com/serde-rs/serde) and [serde_json](https://github.com/serde-rs/json).


Updates `serde` from 1.0.228 to 1.0.229
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.228...v1.0.229)

Updates `serde_json` from 1.0.150 to 1.0.151
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.150...v1.0.151)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.229
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: serde-stack
- dependency-name: serde_json
  dependency-version: 1.0.151
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: serde-stack
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

* fix

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-07-20 20:58:10 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Tarasov Aleksandr
02fdfb6ca8 chore(deps): bump rust-i18n from 4.2.0 to 4.2.1 (#172)
* chore(deps): bump rust-i18n from 4.2.0 to 4.2.1

Bumps [rust-i18n](https://github.com/longbridge/rust-i18n) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/longbridge/rust-i18n/releases)
- [Commits](https://github.com/longbridge/rust-i18n/compare/v4.2.0...v4.2.1)

---
updated-dependencies:
- dependency-name: rust-i18n
  dependency-version: 4.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com>
2026-07-20 19:33:48 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a027838d0e chore(deps): bump tokio from 1.52.3 to 1.53.0 in the tokio-stack group (#170)
* chore(deps): bump tokio from 1.52.3 to 1.53.0 in the tokio-stack group

Bumps the tokio-stack group with 1 update: [tokio](https://github.com/tokio-rs/tokio).


Updates `tokio` from 1.52.3 to 1.53.0
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.3...tokio-1.53.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: tokio-stack
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:22:03 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1828d903a1 chore(deps): bump clap from 4.6.1 to 4.6.3 in the clap-stack group (#171)
* chore(deps): bump clap from 4.6.1 to 4.6.3 in the clap-stack group

Bumps the clap-stack group with 1 update: [clap](https://github.com/clap-rs/clap).


Updates `clap` from 4.6.1 to 4.6.3
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.1...clap_complete-v4.6.3)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: clap-stack
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:21:48 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
83ca7a5e34 chore(deps): bump async-trait from 0.1.89 to 0.1.91 (#173)
* chore(deps): bump async-trait from 0.1.89 to 0.1.91

Bumps [async-trait](https://github.com/dtolnay/async-trait) from 0.1.89 to 0.1.91.
- [Release notes](https://github.com/dtolnay/async-trait/releases)
- [Commits](https://github.com/dtolnay/async-trait/compare/0.1.89...0.1.91)

---
updated-dependencies:
- dependency-name: async-trait
  dependency-version: 0.1.91
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(flatpak): automatically update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:18:56 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f2aca5e34a chore(deps): bump actions/setup-python from 6 to 7 (#168)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 19:13:41 +03:00
6130a99c4b fix(lib): don't create '~' in $HOME (#166)
* fix(lib): don't create '~' in $HOME

* throw an error wen fail to get home directory

---------

Co-authored-by: arabian <a.tevg@ya.ru>
2026-07-20 19:02:10 +03:00
tonocoandGitHub 322b93efa1 fix(scripts): check workspace_package_match and version_match type before passing (#167) 2026-07-19 17:22:46 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
683f4127a0 chore(deps): bump rust-i18n from 4.1.0 to 4.2.0 (#163)
* chore(deps): bump rust-i18n from 4.1.0 to 4.2.0

Bumps [rust-i18n](https://github.com/longbridge/rust-i18n) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/longbridge/rust-i18n/releases)
- [Commits](https://github.com/longbridge/rust-i18n/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: rust-i18n
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-07-15 16:21:05 +03:00
arabianq c873166e21 fix(dependabot): install tomlkit 2026-07-14 16:43:57 +03:00
Tarasov AleksandrandGitHub ec4e873071 refactor: better systemd service (#161)
* move `pwsp-daemon.service` from `pwsp-gui/assets` to pwsp-daemon/assets + autorestart service + better logging + wait for pipewire until start

* adapt all packages

* improve systemd service calling for .deb package
2026-07-07 21:29:26 +03:00
arabianq a91252d7fb change version to 1.12.4 2026-07-07 19:31:00 +03:00
arabianq 239170df45 cargo update 2026-07-07 19:30:15 +03:00
arabianq 5fef42a2e2 cargo fmt 2026-07-07 19:29:48 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
4f8f663f12 fix(pwsp-gui): fix arbitrary file overwrite via path traversal (#159)
* Fix path traversal vulnerability in audio download logic

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* std::path::Path -> Path

* add random id to the filename if it's unknown

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 19:29:12 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
6ccc55a1d0 perf(pwsp-gui): optimize gui file filtering performance (#160)
Avoid unnecessary string cloning and allocation in `pwsp-gui/src/gui/mod.rs` when checking if a file matches the search query.

Specifically, we replaced `.to_string_lossy().to_string()` with `.to_string_lossy()` because `.to_lowercase()` directly works on `Cow<str>`, avoiding an intermediate String allocation.

Measured improvement was verified using an isolated benchmark demonstrating roughly 10-15% reduction in execution time.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 19:00:16 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
d266a3f073 fix(pwsp-lib): fixed unhandled panic on missing config directory (#157)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 18:58:43 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d2993592be chore(deps): bump actions/upload-artifact from 4 to 7 (#153)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 16:56:24 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a53ab62dc chore(deps): bump peaceiris/actions-gh-pages from 3 to 4 (#154)
Bumps [peaceiris/actions-gh-pages](https://github.com/peaceiris/actions-gh-pages) from 3 to 4.
- [Release notes](https://github.com/peaceiris/actions-gh-pages/releases)
- [Changelog](https://github.com/peaceiris/actions-gh-pages/blob/main/CHANGELOG.md)
- [Commits](https://github.com/peaceiris/actions-gh-pages/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peaceiris/actions-gh-pages
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 16:06:50 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
51fcd94a61 chore(deps): bump actions/checkout from 4 to 7 (#155)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 15:59:53 +03:00
arabianq 7f6a122816 ci(flatpak): use /run/ccache for flatpak builds 2026-07-07 15:50:06 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7ca01ed8cb chore(deps): bump stefanzweifel/git-auto-commit-action from 5 to 7 (#156)
Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 15:41:31 +03:00
arabianq 07321afb60 docs(README): change pwsp-gui to pwsp 2026-07-05 22:41:49 +03:00
arabianq 8534e516ad docs(README): change arabianq-repo to pwsp for flatpak repo name 2026-07-05 22:41:15 +03:00
arabianq 16ce233f79 packages(flatapak): fix gpg key for flatpak repo 2026-07-05 22:36:20 +03:00
arabianq c2f4658871 packages(flatpak): add flatpakrepo file generation 2026-07-05 22:22:50 +03:00
arabianq 701bdebc88 packages(aur, rpm, deb): add openssl-devel as build dependency 2026-07-05 21:17:55 +03:00
arabianq d4ae78c7b0 ci(rpm): arabianq/pwsp-git -> arabianq/pwsp 2026-07-05 21:04:07 +03:00
arabianq 9e1d50e30c ci(rpm): add workflow for pwsp-git.rpm 2026-07-05 21:01:05 +03:00
arabianq 85b6c17bd1 deps(flatpak): update cargro-sources.json 2026-07-05 21:00:14 +03:00
arabianq cfe478b02d ci(rpm): replaced rustls with native-tls (OpenSSL) so it can build in copr 2026-07-05 20:56:18 +03:00
arabianq 0b23b3ed51 packages(deb): change package.metadata.deb.name to pwsp 2026-07-05 20:55:27 +03:00
arabianq 728d14006c change version to 1.12.3 2026-07-05 20:20:46 +03:00
arabianq f8bb84bbcf docs(readme): fix screenshot 2026-07-05 19:35:56 +03:00
Tarasov AleksandrandGitHub 0dbcc771f5 docs: new readme (#152) 2026-07-05 19:33:20 +03:00
arabianq b3a786a76c deps(flatpak): update cargo-sources.json 2026-07-05 19:11:08 +03:00
arabianq 972e303bff deps: cargo update 2026-07-05 19:07:46 +03:00
arabianq c406704ce5 fix(ci, flatter): fix building nightly repo 2026-07-05 18:56:32 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd4a35e0e1 chore(deps): bump crazy-max/ghaction-import-gpg from 6 to 7 (#147)
Bumps [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg) from 6 to 7.
- [Release notes](https://github.com/crazy-max/ghaction-import-gpg/releases)
- [Commits](https://github.com/crazy-max/ghaction-import-gpg/compare/v6...v7)

---
updated-dependencies:
- dependency-name: crazy-max/ghaction-import-gpg
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:44:26 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c793d09006 chore(deps): bump actions/setup-python from 5 to 6 (#148)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:44:13 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e4dd6d8b56 chore(deps): bump actions/cache from 4 to 6 (#149)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:58 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
457416779a chore(deps): bump softprops/action-gh-release from 2 to 3 (#150)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:42 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b0ac5490cc chore(deps): bump actions/download-artifact from 4 to 8 (#151)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:27 +03:00
arabianq a728671b01 fix(ci, flatter): fixed building nightly repo 2026-07-05 18:31:38 +03:00
arabianq 08153e4a1c fix(ci, flatter): fixed building nightly repo 2026-07-05 18:22:03 +03:00
arabianq c4699002fd fix(ci, deb): add secion and priority fields to the package.metadata.deb 2026-07-05 18:09:52 +03:00
arabianq dbcbf36df0 ci(deb): add nightly builds 2026-07-05 18:03:22 +03:00
arabianq 873ed9a6db fix github pages 2026-07-05 17:58:38 +03:00
Tarasov AleksandrandGitHub 601a5ce336 ci: refactor and optimize GitHub Actions workflows (#146)
* ci: refactor and optimize GitHub Actions workflows\n\n- **Consolidate build processes (`build.yml`, `release.yml`)**: Merged the standalone `.zip` (linux-build) and `.deb` (deb-build) jobs into a single matrix job (`build-and-package` / `build-release`).
  This prevents compiling the Rust binaries twice per architecture, effectively cutting the build time in half and saving CI runner minutes.
    - **Parallelize and fix Flatpak deployment (`flatter.yml`)**:
      - Converted `x86_64` and `aarch64` flatpak jobs to run concurrently via a matrix strategy, removing the sequential bottleneck.
      - Resolved an issue where GitHub Pages would only host the `aarch64` repository. Introduced a new `prepare-repo` job that downloads the `.flatpak` bundles from both architectures, merges them into a
  single OSTree repository locally, signs it with GPG, and deploys the unified multi-arch repository to GitHub Pages.
      - Configured `flatter.yml` to automatically attach the offline `.flatpak` bundles to GitHub Releases when triggered by a release event.
    - **Toolchain and cleanup**: Switched hardcoded Rust toolchain version (`1.96.0`) to `stable` across all workflows to prevent manual bumps in the future. Simplified bash scripts and removed redundant
  dependency installation steps.

* Extract Copr trigger into a separate job: Copr build now starts immediately in parallel with GitHub Actions builds, eliminating unnecessary waiting time.

* use ubuntu 22.04 to build binaries instead of ubuntu-latest

* fix cache poisoning

* another attemp to fix cache poisoning

* disable cache in setup-rust-toolchain

* use ubuntu-latest

* optimize dependencies in debug profile

* remove --release flag from build.yml and CARGO env vars

* fix flatter names collision

* better flatter key changing

* auto formatting

* add auto updating cargo-sources for dependabot pull requests

* add groups to dependabot.yml

* add github-actions to dependabot

* add ppa deb repo

* add concurrency: gh-pages-deploy
2026-07-05 17:49:38 +03:00
52 changed files with 4398 additions and 2459 deletions
+23
View File
@@ -1,6 +1,29 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
groups:
egui-stack:
patterns:
- "egui*"
- "eframe"
serde-stack:
patterns:
- "serde*"
tokio-stack:
patterns:
- "tokio*"
- "reqwest"
clap-stack:
patterns:
- "clap*"
+126 -73
View File
@@ -12,7 +12,7 @@ on:
workflow_dispatch:
jobs:
linux-build:
build-and-package:
strategy:
matrix:
include:
@@ -33,32 +33,30 @@ jobs:
libclang-dev \
libasound2-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Run tests
run: cargo test --locked
- name: Build all binaries (debug-speed compilation into target/release)
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
CARGO_PROFILE_RELEASE_DEBUG: "true"
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
run: cargo build --release --locked
- name: Build all binaries
run: cargo build --locked
- name: Extract all binary names
id: cargo-meta
@@ -70,7 +68,7 @@ jobs:
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package all binaries into one archive
- name: Package ZIP archive
shell: bash
run: |
set -euo pipefail
@@ -81,7 +79,7 @@ jobs:
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
FILES+=("target/release/$BIN")
FILES+=("target/debug/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then
@@ -89,70 +87,18 @@ jobs:
exit 1
fi
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2
exit 1
fi
echo "Will add: $f"
done
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload archive as artifact
uses: actions/upload-artifact@v4
- name: Upload ZIP artifact
uses: actions/upload-artifact@v7
with:
name: archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 7
deb-build:
strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps:
- name: Install apt deps (dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Build all binaries (debug-speed compilation into target/release)
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
CARGO_PROFILE_RELEASE_DEBUG: "true"
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
run: cargo build --release --locked
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
@@ -161,20 +107,108 @@ jobs:
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package (debug binaries from target/release)
- name: Create .deb package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb -p pwsp-gui --no-build --no-strip
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
BASE_VERSION=$(grep '^version =' Cargo.toml | head -1 | cut -d'"' -f2)
GIT_REV=$(git rev-parse --short HEAD)
DATE=$(date +%Y%m%d)
DEB_VER="${BASE_VERSION}+git${DATE}.${GIT_REV}"
echo "Building nightly deb version: $DEB_VER"
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev --deb-version "$DEB_VER"
else
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev
fi
- name: Upload .deb(s) as artifacts
uses: actions/upload-artifact@v4
- name: Upload .deb artifact
uses: actions/upload-artifact@v7
with:
name: deb-packages-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 7
deploy-nightly-apt:
needs: build-and-package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Download DEB artifacts
uses: actions/download-artifact@v8
with:
pattern: deb-packages-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate Nightly APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to nightly APT repository..."
reprepro includedeb nightly "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
flatpak-build:
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
strategy:
@@ -191,7 +225,7 @@ jobs:
options: --privileged
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
@@ -202,3 +236,22 @@ jobs:
branch: master
build-bundle: true
arch: ${{ matrix.arch }}
trigger-copr-nightly:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Install copr-cli
run: pip install copr-cli
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ github.sha }} \
--spec packages/rpm/pwsp-git.spec \
arabianq/pwsp
+36
View File
@@ -0,0 +1,36 @@
name: Dependabot Flatpak Cargo Sources
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "Cargo.lock"
jobs:
update-cargo-sources:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: write # Needed to push back to the branch
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.x"
- name: Install dependencies
run: pip install aiohttp tomlkit
- name: Generate cargo-sources.json
run: bash ./scripts/generate-sources.sh
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore(flatpak): automatically update cargo-sources.json"
file_pattern: "packages/flatpak/cargo-sources.json"
+96 -55
View File
@@ -21,9 +21,17 @@ on:
default: "stable"
jobs:
flatter-x64:
name: Flatter (x86_64)
runs-on: ubuntu-latest
build-flatpak:
name: Build Flatpak (${{ matrix.arch }})
strategy:
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
permissions:
contents: read
container:
@@ -32,13 +40,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v6
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
@@ -56,6 +64,8 @@ jobs:
- name: Modify Manifest
run: |
sed -i '/^branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
sed -i '/^default-branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
@@ -69,77 +79,108 @@ jobs:
with:
files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-bundles: true
upload-pages-artifact: false
arch: x86_64
arch: ${{ matrix.arch }}
cache: true
flatter-arm64:
name: Flatter (aarch64)
needs: flatter-x64
runs-on: ubuntu-24.04-arm
permissions:
contents: read
prepare-repo:
name: Prepare OSTree Repo
needs: build-flatpak
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
permissions:
contents: write # Needed to upload files to GitHub Release
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Flatpak Bundles
uses: actions/download-artifact@v8
with:
ref: ${{ inputs.tag_name || github.ref }}
path: bundles
pattern: ru.arabianq.pwsp-*
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v6
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Set Default Branch
id: set_branch
- name: Update OSTree Repo and Prepare Release Bundles
run: |
if [ "${{ github.event_name }}" == "release" ]; then
echo "branch=stable" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
mkdir -p release-bundles
# Inherit existing OSTree repository to preserve previous branches (e.g. stable vs nightly)
# Only inherit if objects/ exists, otherwise it's a corrupted/empty repo due to Git ignoring empty folders
if [ -d "gh-pages-branch/objects" ]; then
echo "Found existing OSTree repository. Copying..."
cp -r gh-pages-branch pages-repo
# Clean up the .git folder so it doesn't interfere
rm -rf pages-repo/.git
# Recreate empty OSTree directories that Git ignored when pushing previously
mkdir -p pages-repo/refs/heads pages-repo/refs/mirrors pages-repo/refs/remotes \
pages-repo/objects pages-repo/state pages-repo/extensions pages-repo/tmp
else
echo "branch=nightly" >> $GITHUB_OUTPUT
echo "Initializing new OSTree repository..."
mkdir -p pages-repo
ostree init --mode=archive-z2 --repo=pages-repo
fi
- name: Modify Manifest
run: |
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
shopt -s nullglob
for dir in bundles/ru.arabianq.pwsp-*; do
arch=$(basename "$dir" | sed 's/ru.arabianq.pwsp-//')
- name: Install SDK Extensions
run: |
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
# Find the flatpak file inside the dir
bundle=$(find "$dir" -name "*.flatpak" | head -n 1)
- name: Build Flatpak
uses: andyholmes/flatter@main
if [ -n "$bundle" ]; then
echo "Importing $bundle for architecture $arch..."
flatpak build-import-bundle pages-repo "$bundle"
# Copy with a unique name for GitHub Release
cp "$bundle" "release-bundles/ru.arabianq.pwsp-${arch}.flatpak"
fi
done
# Update summary and sign the repository
flatpak build-update-repo --generate-static-deltas --gpg-sign=${{ steps.gpg.outputs.fingerprint }} pages-repo
# Export GPG public key for manual import
gpg --export ${{ steps.gpg.outputs.fingerprint }} > pages-repo/pwsp.gpg
# Generate a .flatpakrepo file for easy installation
GPG_BASE64=$(gpg --export ${{ steps.gpg.outputs.fingerprint }} | base64 -w 0)
cat <<EOF | sed 's/^ *//' > pages-repo/pwsp.flatpakrepo
[Flatpak Repo]
Title=PipeWire Soundpad
Url=https://arabianq.github.io/pipewire-soundpad/
Homepage=https://github.com/arabianq/pipewire-soundpad
Comment=PipeWire Soundpad Flatpak Repository
Description=Official repository for PWSP
GPGKey=${GPG_BASE64}
EOF
- name: Deploy Flatpak Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-pages-artifact: true
arch: aarch64
cache: true
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./pages-repo
destination_dir: .
keep_files: true
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: flatter-arm64
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
- name: Upload bundles to GitHub Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: release-bundles/*.flatpak
tag_name: ${{ github.event.release.tag_name }}
+117 -112
View File
@@ -23,46 +23,32 @@ jobs:
id: tag
run: |
set -euo pipefail
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG"
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG"
if [ -n "$INPUT_TAG" ]; then
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
elif [ -n "$EVENT_TAG" ]; then
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
elif [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
else
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag found"
else
echo "tag=" >> $GITHUB_OUTPUT
fi
fi
- name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }}
run: |
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
echo "ERROR: No tag determined."
exit 1
linux-release:
build-release:
needs: prepare
strategy:
matrix:
@@ -75,7 +61,7 @@ jobs:
runs-on: ${{ matrix.runner }}
steps:
- name: Install apt deps (jq/zip + dev-libs)
- name: Install apt deps
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -84,10 +70,11 @@ jobs:
libclang-dev \
libasound2-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Checkout code at tag
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
@@ -95,10 +82,13 @@ jobs:
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Extract all binary names
id: cargo-meta
@@ -113,13 +103,12 @@ jobs:
- name: Build all release binaries
run: cargo build --release --locked
- name: Package all binaries into one archive
- name: Package ZIP archive
shell: bash
run: |
set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-${{ matrix.arch }}.zip"
echo "Creating archive: $ARCHIVE_NAME"
FILES=()
while IFS= read -r BIN; do
@@ -127,71 +116,18 @@ jobs:
FILES+=("target/release/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then
echo "Error: no binaries were discovered via cargo metadata." >&2
exit 1
fi
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2
exit 1
fi
echo "Will add: $f"
done
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload zip archive
uses: actions/upload-artifact@v4
- name: Upload ZIP artifact
uses: actions/upload-artifact@v7
with:
name: zip-archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 1
deb-release:
needs: prepare
strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps:
- name: Install apt deps (dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
- name: Rust Cache
uses: swatinem/rust-cache@v2
- name: Build all release binaries
run: cargo build --release --locked
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
@@ -200,50 +136,32 @@ jobs:
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package (release binaries)
- name: Create .deb package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb -p pwsp-gui --no-build
- name: Upload deb package
uses: actions/upload-artifact@v4
- name: Upload DEB artifact
uses: actions/upload-artifact@v7
with:
name: deb-package-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 1
publish-release:
needs: [prepare, linux-release, deb-release]
needs: [prepare, build-release]
runs-on: ubuntu-latest
steps:
- name: Download zip archive (x64)
uses: actions/download-artifact@v4
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
name: zip-archive-x64
path: ./dist
- name: Download zip archive (arm64)
uses: actions/download-artifact@v4
with:
name: zip-archive-arm64
path: ./dist
- name: Download deb package (x64)
uses: actions/download-artifact@v4
with:
name: deb-package-x64
path: ./dist
- name: Download deb package (arm64)
uses: actions/download-artifact@v4
with:
name: deb-package-arm64
path: ./dist
merge-multiple: true
- name: Upload artifacts to Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
@@ -251,6 +169,10 @@ jobs:
./dist/pwsp-*.zip
./dist/*.deb
trigger-copr:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Install copr-cli
run: pip install copr-cli
@@ -265,3 +187,86 @@ jobs:
--commit ${{ needs.prepare.outputs.tag }} \
--spec packages/rpm/pwsp.spec \
arabianq/pwsp
build-apt-repo:
needs: [prepare, build-release]
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Download DEB artifacts
uses: actions/download-artifact@v8
with:
pattern: deb-package-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
# Restore existing repository first so we don't lose old packages
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
# Always rewrite the config and public key to ensure they are up-to-date
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to APT repository..."
reprepro includedeb stable "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
Generated
+515 -670
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -8,7 +8,7 @@ members = [
resolver = "2"
[workspace.package]
version = "1.12.2"
version = "1.15.0"
edition = "2024"
authors = ["arabian"]
homepage = "https://pwsp.arabianq.ru"
@@ -52,7 +52,7 @@ rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "4746803c2e5f67004264ac946708d47a9faf510c", default-features = false, features = [
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "c5f1e94290922fe4ee963ce6f85754ea6ba36243", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
@@ -73,13 +73,18 @@ egui_extras = "0.35.0"
egui_material_icons = "0.7.0"
egui_dnd = "0.16.0"
reqwest = "0.13.4"
reqwest = { version = "0.13.4", default-features = false, features = ["native-tls"] }
percent-encoding = "2.3.2"
[profile.dev]
[profile.dev.package."*"]
opt-level = 3
debug = false
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
+167 -66
View File
@@ -1,118 +1,219 @@
<div align="center">
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
<img src="pwsp-gui/assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
</div>
# PipeWire Soundpad (PWSP) 🎵
## 🌟 Overview
**PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
<table border="0">
<tr>
<td colspan="2" align="center">
<h3>Main Screen</h3>
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
</td>
</tr>
<tr>
<td width="50%" align="center">
<h3>Settings</h3>
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
</td>
<td width="50%" align="center">
<h3>Hotkeys</h3>
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
</td>
</tr>
</table>
## ✨ Key Features
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
[🇷🇺 Читать на русском](README.ru.md) | 🇬🇧 English (you are here)
## ⚙️ Architecture
PWSP is built with a client-server model to ensure stability and separation of concerns:
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback.
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings.
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
**PipeWire Soundpad (PWSP)** is a modern application that lets you play audio files directly through your microphone. Designed specifically for Linux, it talks to PipeWire natively: it creates its own virtual microphone and wires everything up itself, so you never have to build a routing setup by hand.
---
## ✨ Features
- **Native PipeWire Integration:** PWSP creates its own virtual microphone and manages the graph through the PipeWire API — no manual `pw-link` juggling, no PulseAudio modules to load.
- **Separate Monitoring and Microphone Volumes:** Two independent streams, so you can amplify what your friends hear without deafening yourself, and pick which device the monitoring plays on.
- **Modular Architecture:** Consists of a background `daemon`, a command-line interface (`cli`), and a graphical user interface (`gui`).
- **Modern GUI:** Built with `egui`, supporting both Wayland and X11 seamlessly.
- **Localized:** Ships in 9 languages, following your system locale or one you pick yourself in the settings.
- **Global Hotkeys:** Powered by `evdev`, allowing you to play sounds from anywhere.
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats, including Opus.
---
## 🚀 Installation
We provide multiple ways to install PWSP, including stable releases and rolling "nightly" builds directly from the `main` branch.
### 📦 Flatpak (Recommended)
Install PWSP via Flatpak from our custom repository:
Add our official OSTree repository and install the application:
```bash
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
# Add the repository
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Install stable version
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
# Install the Stable version
flatpak install pwsp ru.arabianq.pwsp//stable
# Or install the nightly version (latest commit)
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
# OR Install the Nightly version (rolling updates)
flatpak install pwsp ru.arabianq.pwsp//nightly
```
### 🐧 Linux Packages
**Fedora (and derivatives):**
### 🟠 Debian / Ubuntu (APT Repository)
We maintain an official APT repository for seamless updates via `apt`:
```bash
# 1. Download the public GPG key
sudo mkdir -p /etc/apt/keyrings
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Add the repository (Choose STABLE or NIGHTLY)
# For Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# For Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Update and install
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Available via the Fedora COPR repository:
```bash
sudo dnf copr enable arabianq/pwsp
sudo dnf install pwsp
```
**Arch Linux (AUR):**
### 🐦 Arch Linux (AUR)
Two packages are available: `pwsp` builds from source, `pwsp-bin` installs the prebuilt binaries.
```bash
paru -S pwsp-bin # or 'pwsp' to build from source
# Using your preferred AUR helper
paru -S pwsp # or: pwsp-bin
```
**Debian / Ubuntu:**
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### ⚙️ Manual / Standalone
You can manually download `.deb` packages or standalone `.zip` binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Build from Source
Make sure you have Rust and Cargo installed, along with the build dependencies. On Debian/Ubuntu:
### 🦀 Cargo / Source Build
```bash
cargo install pwsp
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
```
# OR clone and build manually:
```bash
git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad
cargo build --release
cargo build --release --locked
```
*(Note: Requires Rust toolchain and PipeWire running on your system).*
The binaries will be located in `target/release/`.
---
## 🎮 Usage
### 1. Start the Daemon
Before using the GUI or CLI, the daemon must be running in the background.
PWSP operates via a background daemon that handles the audio routing.
```bash
# Recommended: Start and enable via systemd (starts on login)
systemctl --user enable --now pwsp-daemon
# Manual start (if not using systemd):
pwsp-daemon &
# Run the daemon
pwsp-daemon
```
### 2. Using the GUI
1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard.
3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
_(Tip: If installed via package managers, a systemd user service is provided. You can enable it with `systemctl --user enable --now pwsp-daemon.service`)_
### 2. Launch the GUI
Simply run the graphical interface to manage and play your sounds:
```bash
pwsp-gui
```
### 3. Use the CLI
You can also interact with the daemon directly via the command line. Every command is grouped under `action`, `get` or `set`:
### 3. Using the CLI
Control the daemon directly from your terminal:
```bash
pwsp-cli action play /path/to/sound.mp3
pwsp-cli get volume
pwsp-cli set position 20
pwsp-cli --help # View all commands
pwsp-cli action stop
pwsp-cli get state
# Separate volumes for what you hear and what is sent to the microphone.
# Values above 1.0 amplify.
pwsp-cli set monitoring-volume 0.3
pwsp-cli set mic-volume 1.5
# Devices
pwsp-cli get inputs
pwsp-cli set input <name>
```
Run `pwsp-cli <group> --help` for the full list.
### 🔑 Enabling Global Hotkeys
Hotkeys are read straight from the kernel via `evdev`, which means the daemon needs access to `/dev/input/event*`. On most distributions that means adding yourself to the `input` group:
```bash
sudo usermod -aG input $USER
```
Log out and back in for it to take effect. Without this, everything else works — only the global hotkeys stay silent.
### 📦 A Note for Flatpak Users
The Flatpak ships a single entry point, so the commands above are reached through `flatpak run`:
```bash
# No arguments: starts the daemon and the GUI together
flatpak run ru.arabianq.pwsp
# The daemon on its own
flatpak run ru.arabianq.pwsp daemon --start
flatpak run ru.arabianq.pwsp daemon --kill
# Anything after "cli" is passed to pwsp-cli
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
```
---
## ⌨️ Shortcuts & Controls
## 📚 Documentation & DeepWiki
| Action | Keyboard | Mouse |
| :----------------------------------- | :--------------------- | :------------------- |
| **Play Track** (Stops others) | | `Left Click` |
| **Add Track** (Plays simultaneously) | | `Ctrl + Left Click` |
| **Replace Last Track** | | `Shift + Left Click` |
| **Pause / Resume** | `Space` | |
| **Stop All Tracks** | `Backspace` | |
| **Open / Close Settings** | `I` | |
| **Search** | `/` | |
DeepWiki generates a browsable overview of the architecture from the source, and lets you ask questions about it. It is generated automatically rather than written by hand, so treat it as a map, not as a specification:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
---
## 🤝 Contributing
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
Contributions, issues, and feature requests are welcome!
## 📜 License
This project is licensed under the [MIT License](LICENSE).
1. Fork the project.
2. Create your feature branch (`git checkout -b feat/amazing-feature`).
3. Commit your changes (`git commit -m 'Add some amazing feature'`).
4. Push to the branch (`git push origin feat/amazing-feature`).
5. Open a Pull Request.
---
## 📝 License
Distributed under the MIT License. See `LICENSE` for more information.
_Built with ❤️ for the Linux community._
+219
View File
@@ -0,0 +1,219 @@
# PipeWire Soundpad (PWSP) 🎵
<table border="0">
<tr>
<td colspan="2" align="center">
<h3>Главная страница</h3>
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
</td>
</tr>
<tr>
<td width="50%" align="center">
<h3>Настройки</h3>
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
</td>
<td width="50%" align="center">
<h3>Горячие клавиши</h3>
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
</td>
</tr>
</table>
🇷🇺 Русский (вы здесь) | [🇬🇧 Read in English](README.md)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
**PipeWire Soundpad (PWSP)** — приложение, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux и работает с PipeWire напрямую: создаёт собственный виртуальный микрофон и само выстраивает маршрутизацию, так что настраивать её руками не придётся.
---
## ✨ Возможности
- **Нативная интеграция с PipeWire:** PWSP создаёт собственный виртуальный микрофон и управляет графом через PipeWire API — без ручного `pw-link` и без подгрузки модулей PulseAudio.
- **Раздельная громкость мониторинга и микрофона:** Два независимых потока: можно усилить то, что слышат собеседники, не оглушив себя, и выбрать устройство для мониторинга.
- **Модульная архитектура:** Состоит из фонового демона (`daemon`), интерфейса командной строки (`cli`) и графического интерфейса (`gui`).
- **Современный GUI:** Построен на `egui`, плавно работает как на Wayland, так и на X11.
- **Локализация:** 9 языков; используется системный или выбранный вручную в настройках.
- **Глобальные горячие клавиши:** Работают через `evdev`, позволяя воспроизводить звуки из любого окна.
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` и поддерживает большинство аудиоформатов, включая Opus.
---
## 🚀 Установка
Мы предоставляем несколько способов установки PWSP, включая стабильные релизы и постоянно обновляемые "nightly" сборки напрямую из ветки `main`.
### 📦 Flatpak (Рекомендуется)
Добавьте наш официальный OSTree репозиторий и установите приложение:
```bash
# Добавьте репозиторий
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Установите стабильную (Stable) версию
flatpak install pwsp ru.arabianq.pwsp//stable
# ИЛИ установите Nightly версию (самые свежие обновления)
flatpak install pwsp ru.arabianq.pwsp//nightly
```
### 🟠 Debian / Ubuntu (APT Репозиторий)
Мы поддерживаем официальный APT-репозиторий для бесшовных обновлений через `apt`:
```bash
# 1. Скачайте публичный GPG ключ
sudo mkdir -p /etc/apt/keyrings
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Добавьте репозиторий (Выберите STABLE или NIGHTLY)
# Для Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# Для Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Обновите индексы и установите
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Доступно через репозиторий Fedora COPR:
```bash
sudo dnf copr enable arabianq/pwsp
sudo dnf install pwsp
```
### 🐦 Arch Linux (AUR)
Доступны два пакета: `pwsp` собирается из исходников, `pwsp-bin` ставит готовые бинарники.
```bash
# Через любой удобный AUR-хелпер
paru -S pwsp # или: pwsp-bin
```
### ⚙️ Ручная установка
Вы можете вручную скачать пакеты `.deb` или готовые бинарники `.zip` на [странице релизов](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Сборка из исходников
Убедитесь, что у вас установлены Rust и Cargo, а также зависимости для сборки. Для Debian/Ubuntu:
```bash
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
```
```bash
git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad
cargo build --release --locked
```
Собранные бинарники будут находиться в папке `target/release/`.
---
## 🎮 Использование
### 1. Запуск демона
PWSP работает через фоновый демон, который маршрутизирует аудио.
```bash
# Запуск демона
pwsp-daemon
```
_(Подсказка: Если вы установили программу через пакетный менеджер, она включает пользовательский systemd-сервис. Вы можете включить его командой: `systemctl --user enable --now pwsp-daemon.service`)_
### 2. Запуск GUI
Просто запустите графический интерфейс для управления и воспроизведения звуков:
```bash
pwsp-gui
```
### 3. Использование CLI
Вы также можете взаимодействовать с демоном напрямую через командную строку. Все команды сгруппированы в `action`, `get` и `set`:
```bash
pwsp-cli action play /path/to/sound.mp3
pwsp-cli action stop
pwsp-cli get state
# Раздельная громкость: что слышите вы и что уходит в микрофон.
# Значения выше 1.0 усиливают.
pwsp-cli set monitoring-volume 0.3
pwsp-cli set mic-volume 1.5
# Устройства
pwsp-cli get inputs
pwsp-cli set input <имя>
```
Полный список — `pwsp-cli <группа> --help`.
### 🔑 Включение глобальных горячих клавиш
Горячие клавиши читаются напрямую из ядра через `evdev`, поэтому демону нужен доступ к `/dev/input/event*`. В большинстве дистрибутивов для этого достаточно добавить себя в группу `input`:
```bash
sudo usermod -aG input $USER
```
Изменение вступит в силу после перезахода в систему. Без него всё остальное работает — молчат только глобальные горячие клавиши.
### 📦 Замечание для пользователей Flatpak
У Flatpak-версии одна точка входа, поэтому команды выше вызываются через `flatpak run`:
```bash
# Без аргументов: запускает демон и GUI вместе
flatpak run ru.arabianq.pwsp
# Отдельно демон
flatpak run ru.arabianq.pwsp daemon --start
flatpak run ru.arabianq.pwsp daemon --kill
# Всё после "cli" передаётся в pwsp-cli
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
```
---
## 📚 Документация и DeepWiki
DeepWiki автоматически строит по исходникам обзор архитектуры и позволяет задавать по нему вопросы. Он генерируется, а не пишется руками, поэтому воспринимайте его как карту, а не как спецификацию:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
---
## 🤝 Вклад в проект (Contributing)
Будем рады вашей помощи, баг-репортам и идеям!
1. Сделайте Fork проекта.
2. Создайте свою ветку (`git checkout -b feat/amazing-feature`).
3. Закоммитьте изменения (`git commit -m 'Add some amazing feature'`).
4. Запушьте ветку (`git push origin feat/amazing-feature`).
5. Откройте Pull Request.
---
## 📝 Лицензия
Распространяется под лицензией MIT. Подробнее см. в файле `LICENSE`.
_Сделано с ❤️ для сообщества Linux._
+4 -4
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.12.2
pkgver = 1.15.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
@@ -10,11 +10,11 @@ pkgbase = pwsp-bin
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pipewire-soundpad-1.12.2.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.2.tar.gz
source = pipewire-soundpad-1.15.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
sha256sums = SKIP
source_x86_64 = pwsp-1.12.2-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.2/pwsp-v1.12.2-linux-x64.zip
source_x86_64 = pwsp-1.15.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-x64.zip
sha256sums_x86_64 = SKIP
source_aarch64 = pwsp-1.12.2-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.2/pwsp-v1.12.2-linux-arm64.zip
source_aarch64 = pwsp-1.15.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-arm64.zip
sha256sums_aarch64 = SKIP
pkgname = pwsp-bin
+3 -3
View File
@@ -1,13 +1,13 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.12.2
pkgver=1.15.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib')
depends=('pipewire' 'alsa-lib' 'openssl')
provides=('pwsp')
conflicts=('pwsp')
@@ -28,7 +28,7 @@ package() {
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/pwsp-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.12.2
pkgver = 1.15.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
@@ -12,7 +12,7 @@ pkgbase = pwsp
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.2.tar.gz
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
sha256sums = SKIP
pkgname = pwsp
+3 -2
View File
@@ -1,12 +1,13 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.12.2
pkgver=1.15.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib' 'openssl')
makedepends=(clang rust cargo cmake pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
@@ -43,5 +44,5 @@ package() {
install -Dm644 "pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "pwsp-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
File diff suppressed because one or more lines are too long
@@ -15,7 +15,13 @@
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/main.png</image>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/settings.png</image>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/hotkeys.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
@@ -25,7 +31,7 @@
<name>arabian</name>
</developer>
<releases>
<release version="1.12.2" date="2026-07-02" />
<release version="1.15.0" date="2026-07-26" />
</releases>
<content_rating type="oars-1.1" />
</component>
+1
View File
@@ -30,6 +30,7 @@ modules:
- name: pwsp
buildsystem: simple
build-commands:
- if [ -w /run/ccache ]; then mkdir -p /run/ccache/cargo-target && rm -rf target && ln -s /run/ccache/cargo-target target; fi
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
+12 -1
View File
@@ -23,9 +23,11 @@ BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
@@ -66,7 +68,16 @@ install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
install -Dm644 pwsp-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%post
%systemd_user_post pwsp-daemon.service
%preun
%systemd_user_preun pwsp-daemon.service
%postun
%systemd_user_postun_with_restart pwsp-daemon.service
%files
%license LICENSE
+13 -2
View File
@@ -13,7 +13,7 @@
Name: pwsp
Version: 1.12.2
Version: 1.15.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -28,9 +28,11 @@ BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
@@ -66,7 +68,16 @@ install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
install -Dm644 pwsp-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%post
%systemd_user_post pwsp-daemon.service
%preun
%systemd_user_preun pwsp-daemon.service
%postun
%systemd_user_postun_with_restart pwsp-daemon.service
%files
%license LICENSE
+37 -4
View File
@@ -15,17 +15,17 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// Perform an action (ping, pause, resume, toggle-pause, stop, play)
/// Tell the player to do something: play, stop, pause, manage hotkeys
Action {
#[clap(subcommand)]
action: Actions,
},
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs)
/// Read player state: volumes, position, tracks, devices, hotkeys
Get {
#[clap(subcommand)]
parameter: GetCommands,
},
/// Set information in the player (volume, position, input)
/// Change player settings: volumes, position, devices, loop, hotkeys
Set {
#[clap(subcommand)]
parameter: SetCommands,
@@ -69,6 +69,8 @@ enum Actions {
#[clap(short, long)]
id: Option<u32>,
},
/// Write the daemon's current settings to disk
SaveDaemonConfig,
/// Play a sound by hotkey slot name
PlayHotkey { slot: String },
/// Remove the hotkey slot
@@ -86,6 +88,12 @@ enum GetCommands {
#[clap(short, long)]
id: Option<u32>,
},
/// Volume of what you hear locally
MonitoringVolume,
/// Volume of what is sent to the virtual microphone
MicVolume,
/// Volume multiplier for all tracks
VolumeMultiplier,
/// Playback position (in seconds)
Position {
#[clap(short, long)]
@@ -104,8 +112,14 @@ enum GetCommands {
Input,
/// All audio inputs
Inputs,
/// Current audio output used for monitoring
Output,
/// All audio outputs
Outputs,
/// Version of the daemon
DaemonVersion,
/// Daemon configuration
DaemonConfig,
/// Full player state
FullState,
/// All hotkey slots
@@ -114,12 +128,18 @@ enum GetCommands {
#[derive(Subcommand, Debug)]
enum SetCommands {
/// Playback volume
/// Playback volume. Without --id it sets both the monitoring and mic volumes
Volume {
volume: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Volume of what you hear locally. Values above 1.0 amplify
MonitoringVolume { volume: f32 },
/// Volume of what is sent to the virtual microphone. Values above 1.0 amplify
MicVolume { volume: f32 },
/// Volume multiplier for all tracks
VolumeMultiplier { volume: f32 },
/// Playback position (in seconds)
Position {
position: f32,
@@ -128,6 +148,8 @@ enum SetCommands {
},
/// Audio input id (see pwsp-cli get inputs)
Input { name: String },
/// Audio output used for monitoring (see pwsp-cli get outputs)
Output { name: String },
/// Enable or disable loop (true or false)
Loop {
enabled: String,
@@ -165,6 +187,7 @@ async fn main() -> Result<()> {
concurrent,
} => Request::play(&file_path.to_string_lossy(), concurrent),
Actions::ToggleLoop { id } => Request::toggle_loop(id),
Actions::SaveDaemonConfig => Request::save_daemon_config(),
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
@@ -172,20 +195,30 @@ async fn main() -> Result<()> {
Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume { id } => Request::get_volume(id),
GetCommands::MonitoringVolume => Request::get_monitoring_volume(),
GetCommands::MicVolume => Request::get_mic_volume(),
GetCommands::VolumeMultiplier => Request::get_volume_multiplier(),
GetCommands::Position { id } => Request::get_position(id),
GetCommands::Duration { id } => Request::get_duration(id),
GetCommands::State => Request::get_state(),
GetCommands::Tracks => Request::get_tracks(),
GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(),
GetCommands::Output => Request::get_output(),
GetCommands::Outputs => Request::get_outputs(),
GetCommands::DaemonVersion => Request::get_daemon_version(),
GetCommands::DaemonConfig => Request::get_daemon_config(),
GetCommands::FullState => Request::get_full_state(),
GetCommands::Hotkeys => Request::get_hotkeys(),
},
Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
SetCommands::MonitoringVolume { volume } => Request::set_monitoring_volume(volume),
SetCommands::MicVolume { volume } => Request::set_mic_volume(volume),
SetCommands::VolumeMultiplier { volume } => Request::set_volume_multiplier(volume),
SetCommands::Position { position, id } => Request::seek(position, id),
SetCommands::Input { name } => Request::set_input(&name),
SetCommands::Output { name } => Request::set_output(&name),
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
SetCommands::Hotkey { slot, file_path } => {
Request::set_hotkey(&slot, &file_path.to_string_lossy())
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Pipewire Soundpad Daemon
Requires=pipewire.service
After=pipewire.service
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/pwsp-daemon
Restart=on-failure
RestartSec=3
RuntimeDirectory=pwsp
SyslogIdentifier=pwsp-daemon
[Install]
WantedBy=default.target
+6 -1
View File
@@ -20,6 +20,7 @@ anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
egui.workspace = true
eframe.workspace = true
@@ -35,6 +36,10 @@ reqwest.workspace = true
percent-encoding.workspace = true
[package.metadata.deb]
name = "pwsp"
section = "sound"
priority = "optional"
maintainer-scripts = "maintainer-scripts"
assets = [
[
"target/release/pwsp-daemon",
@@ -62,7 +67,7 @@ assets = [
"644",
],
[
"assets/pwsp-daemon.service",
"../pwsp-daemon/assets/pwsp-daemon.service",
"usr/lib/systemd/user/pwsp-daemon.service",
"644",
],
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service]
ExecStart=/usr/bin/pwsp-daemon
Restart=no
RuntimeDirectory=pwsp
[Install]
WantedBy=default.target
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+99
View File
@@ -217,6 +217,28 @@ kz = "Әрқашан дыбыс деңгейін есте сақтау"
he = "זכור תמיד עוצמת קול"
pt-BR = "Lembrar volume"
[gui.settings.remember_volume_multiplier]
en = "Always remember volume multiplier"
ru = "Всегда запоминать множитель громкости"
es = "Recordar siempre el multiplicador de volumen"
fr = "Toujours se souvenir du multiplicateur de volume"
zh = "始终记住音量乘数"
ar = "تذكر ضرب الصوت دائمًا"
kz = "Әрқашан дыбыс деңгейін есте сақтау"
he = "זכור תמיד עוצמת קול"
pt-BR = "Lembrar volume"
[gui.settings.volume_multiplier]
en = "Volume multiplier"
ru = "Множитель громкости"
es = "Multiplicador de volumen"
fr = "Multiplicateur de volume"
zh = "音量乘数"
ar = "ضرب الصوت"
kz = "Дыбыс деңгейі"
he = "עוצמת קול"
pt-BR = "Volume"
[gui.settings.remember_mic]
en = "Always remember microphone"
ru = "Всегда запоминать микрофон"
@@ -331,6 +353,17 @@ kz = "Ыстық пернелерді іздеу..."
he = "חפש מקשי קיצור..."
pt-BR = "Buscar atalhos..."
[gui.search_truncated_warning]
en = "... and %{count} more results"
ru = "... и ещё %{count} результатов"
es = "... y %{count} resultados más"
fr = "... et %{count} résultats de plus"
zh = "... 还有 %{count} 个结果"
ar = "... و %{count} نتائج أخرى"
kz = "... және тағы %{count} нәтиже"
he = "... ועוד %{count} תוצאות"
pt-BR = "... e mais %{count} resultados"
[gui.hotkeys.add_command_select]
en = "Add Command"
ru = "Добавить команду"
@@ -484,3 +517,69 @@ ar = "اضغط Esc للإلغاء"
kz = "Болдырмау үшін Escape пернесін басыңыз"
he = "לחץ על Escape לביטול"
pt-BR = "Pressione Esc para cancelar"
[gui.choose_output_select]
en = "Select output"
ru = "Выбрать вывод"
es = "Seleccionar salida"
fr = "Sélectionner la sortie"
zh = "选择输出"
ar = "اختر المخرج"
kz = "Шығысты таңдау"
he = "בחר פלט"
pt-BR = "Selecionar saída"
[gui.default_output]
en = "System default"
ru = "Системный по умолчанию"
es = "Predeterminado del sistema"
fr = "Défaut du système"
zh = "系统默认"
ar = "افتراضي النظام"
kz = "Жүйелік әдепкі"
he = "ברירת מחדל של המערכת"
pt-BR = "Padrão do sistema"
[gui.monitoring_volume]
en = "Monitoring volume"
ru = "Громкость мониторинга"
es = "Volumen de monitorización"
fr = "Volume d'écoute"
zh = "监听音量"
ar = "مستوى صوت المراقبة"
kz = "Мониторинг дыбысы"
he = "עוצמת ניטור"
pt-BR = "Volume de monitoramento"
[gui.mic_volume]
en = "Microphone volume"
ru = "Громкость микрофона"
es = "Volumen del micrófono"
fr = "Volume du microphone"
zh = "麦克风音量"
ar = "مستوى صوت الميكروفون"
kz = "Микрофон дыбысы"
he = "עוצמת מיקרופון"
pt-BR = "Volume do microfone"
[gui.settings.language.label]
en = "Language"
ru = "Язык"
es = "Idioma"
fr = "Langue"
zh = "语言"
ar = "اللغة"
kz = "Тіл"
he = "שפה"
pt-BR = "Idioma"
[gui.settings.language.system]
en = "System"
ru = "Системный"
es = "Sistema"
fr = "Système"
zh = "系统"
ar = "النظام"
kz = "Жүйе"
he = "מערכת"
pt-BR = "Sistema"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
if [ -x "/usr/bin/systemctl" ]; then
systemctl --global enable pwsp-daemon.service || true
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user try-restart pwsp-daemon.service" "$username" || true
fi
fi
done
fi
fi
exit 0
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
if [ "$1" = "remove" ]; then
if [ -x "/usr/bin/systemctl" ]; then
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
fi
fi
done
fi
fi
exit 0
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -e
if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then
if [ -x "/usr/bin/systemctl" ]; then
systemctl --global disable pwsp-daemon.service || true
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user stop pwsp-daemon.service" "$username" || true
fi
fi
done
fi
fi
exit 0
+7 -6
View File
@@ -92,7 +92,7 @@ impl SoundpadGui {
}
pub fn handle_input(&mut self, ctx: &Context) {
let _modifiers = self.modifiers(ctx);
let modifiers = self.modifiers(ctx);
let search_focused = {
if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id
@@ -185,14 +185,15 @@ impl SoundpadGui {
}
// Focus search field
if self.key_pressed(ctx, Key::Slash) {
if search_focused {
if modifiers.ctrl && self.key_pressed(ctx, Key::F) {
self.app_state.force_focus_search = true;
}
// Unfocus search field
if search_focused && self.key_pressed(ctx, Key::Escape) {
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
});
} else {
self.app_state.force_focus_search = true;
}
}
// Check for hotkey chord triggers
+174 -30
View File
@@ -9,22 +9,24 @@ use itertools::Itertools;
use pwsp_lib::{
types::{
audio_player::PlayerState,
config::GuiConfig,
config::HotkeyConfig,
config::{GuiConfig, HotkeyConfig},
gui::{AppState, AudioPlayerState},
socket::Request,
},
utils::{
daemon::get_daemon_config,
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread},
utils::gui::{
get_daemon_config, get_gui_config, make_request_async, make_request_sync,
start_app_state_thread, update_daemon_config,
},
};
use rfd::FileDialog;
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
@@ -32,6 +34,16 @@ const SUPPORTED_EXTENSIONS: [&str; 13] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
];
fn get_cache_file_for(path: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let hash = hasher.finish();
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("pwsp")
.join(format!("{:x}.json", hash))
}
struct SoundpadGui {
pub app_state: AppState,
pub config: GuiConfig,
@@ -107,20 +119,120 @@ impl SoundpadGui {
}
}
pub fn open_dir(&mut self, path: &PathBuf) {
pub fn open_dir(&mut self, path: &PathBuf, ctx: Option<Context>, force_rescan: bool) {
self.app_state.current_dir = Some(path.clone());
match path.read_dir() {
if !self.app_state.dir_cache.contains_key(path) || force_rescan {
match fs::read_dir(path) {
Ok(read_dir) => {
self.app_state.listed_files = read_dir
let files = read_dir
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect();
.collect::<Vec<_>>();
self.app_state.listed_files = files.iter().cloned().collect();
self.app_state.dir_cache.insert(path.clone(), files);
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.listed_files.clear();
}
}
} else {
self.app_state.listed_files = self
.app_state
.dir_cache
.get(path)
.unwrap()
.clone()
.into_iter()
.collect();
}
let is_scanning = self.app_state.scanning_dirs.lock().unwrap().contains(path);
let is_cached = self.app_state.recursive_files_cache.contains_key(path);
let has_scanned_this_session = self.app_state.scanned_this_session.contains(path);
let needs_scan = force_rescan || !has_scanned_this_session || !is_cached;
if !is_scanning && needs_scan {
self.app_state
.scanning_dirs
.lock()
.unwrap()
.insert(path.clone());
self.app_state.scanned_this_session.insert(path.clone());
let finished_scans = self.app_state.finished_scans.clone();
let scanning_dirs = self.app_state.scanning_dirs.clone();
let path_clone = path.clone();
thread::spawn(move || {
let cache_file = get_cache_file_for(&path_clone);
// 1. Try to load from disk cache if we don't have it in memory yet
if !is_cached
&& let Ok(data) = fs::read_to_string(&cache_file)
&& let Ok((all_files, dir_updates)) = serde_json::from_str::<(
Vec<PathBuf>,
HashMap<PathBuf, Vec<PathBuf>>,
)>(&data)
{
finished_scans.lock().unwrap().push((
path_clone.clone(),
all_files,
dir_updates,
));
if let Some(ctx) = ctx.as_ref() {
ctx.request_repaint();
}
}
// 2. Scan recursively
let mut all_files = Vec::new();
let mut dir_updates = HashMap::new();
let mut dirs_to_visit = vec![path_clone.clone()];
while let Some(dir) = dirs_to_visit.pop() {
if let Ok(entries) = fs::read_dir(&dir) {
let mut children = Vec::new();
for entry in entries.filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_dir() {
dirs_to_visit.push(p.clone());
children.push(p);
} else if crate::gui::SUPPORTED_EXTENSIONS.contains(
&p.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
all_files.push(p.clone());
children.push(p);
}
}
dir_updates.insert(dir, children);
}
}
// 3. Save to disk cache
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string(&(all_files.clone(), dir_updates.clone())) {
fs::write(&cache_file, json).ok();
}
// 4. Send to UI
finished_scans
.lock()
.unwrap()
.push((path_clone.clone(), all_files, dir_updates));
scanning_dirs.lock().unwrap().remove(&path_clone);
if let Some(ctx) = ctx {
ctx.request_repaint();
}
});
}
}
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
@@ -130,10 +242,23 @@ impl SoundpadGui {
pub fn set_input(&mut self, name: String) {
make_request_async(Request::set_input(&name));
if self.config.save_input {
let mut daemon_config = get_daemon_config();
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
daemon_config.default_input_name = Some(name);
daemon_config.save_to_file().ok();
update_daemon_config(&daemon_config).ok();
}
}
pub fn set_output(&mut self, name: String) {
make_request_async(Request::set_output(&name));
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
// Empty means "follow the system default", which is stored as no device.
daemon_config.default_output_name = Some(name).filter(|n| !n.is_empty());
update_daemon_config(&daemon_config).ok();
}
}
@@ -157,7 +282,14 @@ impl SoundpadGui {
make_request_async(Request::play_hotkey(slot));
}
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
pub fn get_filtered_files(
&self,
matching_dirs: &HashSet<PathBuf>,
matching_files: &HashSet<PathBuf>,
) -> Vec<PathBuf> {
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
let sort_order = self
.app_state
@@ -178,33 +310,42 @@ impl SoundpadGui {
}
});
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
let is_cached = self
.app_state
.current_dir
.as_ref()
.is_some_and(|d| self.app_state.recursive_files_cache.contains_key(d));
if !search_query.is_empty() && is_cached {
files
.into_iter()
.filter(|p| {
if p.is_dir() {
matching_dirs.contains(p)
} else {
matching_files.contains(p)
}
})
.collect()
} else {
files
.into_iter()
.filter(|entry_path| {
if entry_path.is_dir() {
return true;
}
if !SUPPORTED_EXTENSIONS.contains(
if !entry_path.is_dir()
&& !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
)
{
return false;
}
if !search_query.is_empty() {
let file_name = entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let file_name =
entry_path.file_name().unwrap_or_default().to_string_lossy();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
@@ -215,6 +356,7 @@ impl SoundpadGui {
.collect()
}
}
}
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
@@ -329,14 +471,16 @@ mod tests {
// On the real OS filesystem, these paths don't exist, so they are treated as files.
// Unsupported extensions (like .txt) will be filtered out.
// So we expect only file_b and file_c, sorted alphabetically.
let filtered = gui.get_filtered_files();
let empty_dirs = HashSet::new();
let empty_files = HashSet::new();
let filtered = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0], file_b);
assert_eq!(filtered[1], file_c);
// Test search query
gui.app_state.search_query = "c_fi".to_string();
let filtered_search = gui.get_filtered_files();
let filtered_search = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered_search.len(), 1);
assert_eq!(filtered_search[0], file_c);
@@ -349,7 +493,7 @@ mod tests {
},
);
gui.app_state.search_query = String::new();
let filtered_desc = gui.get_filtered_files();
let filtered_desc = gui.get_filtered_files(&empty_dirs, &empty_files);
assert_eq!(filtered_desc.len(), 2);
assert_eq!(filtered_desc[0], file_c);
assert_eq!(filtered_desc[1], file_b);
+37 -39
View File
@@ -2,10 +2,25 @@ use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context, ThemePreference};
use pwsp_lib::{
types::{config::PreferredTheme, socket::Request},
utils::{daemon::get_daemon_config, gui::make_request_async},
types::{
config::{DaemonConfig, PreferredTheme},
socket::Request,
},
utils::gui::{get_daemon_config, make_request_async, update_daemon_config},
};
use std::time::{Duration, Instant};
impl SoundpadGui {
/// Persists a master volume to the daemon config when the user asked us to remember it.
fn remember_volume(&self, apply: impl FnOnce(&mut DaemonConfig)) {
if !self.config.save_volume {
return;
}
if let Ok(mut daemon_config) = get_daemon_config() {
apply(&mut daemon_config);
update_daemon_config(&daemon_config).ok();
}
}
}
impl App for SoundpadGui {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
@@ -43,52 +58,35 @@ impl App for SoundpadGui {
self.config.save_to_file().ok();
}
// Seek and volume requests
let mut seek_requests = vec![];
let mut volume_requests = vec![];
// Per-track seek and volume requests
for (id, ui_state) in &mut self.app_state.track_ui_states {
if ui_state.position_dragged {
seek_requests.push((*id, ui_state.position_slider_value));
if let Some(position) = ui_state.position.take_pending() {
make_request_async(Request::seek(position, Some(*id)));
}
if ui_state.volume_dragged {
volume_requests.push((*id, ui_state.volume_slider_value));
ui_state.volume_dragged = false;
if let Some(volume) = ui_state.volume.take_pending() {
make_request_async(Request::set_volume(volume, Some(*id)));
}
}
for (id, pos) in seek_requests {
make_request_async(Request::seek(pos, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.position_dragged = false;
ui_state.ignore_position_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
// Master volumes
if let Some(volume) = self.app_state.monitoring_volume.take_pending() {
make_request_async(Request::set_monitoring_volume(volume));
self.remember_volume(|config| config.default_monitoring_volume = Some(volume));
}
for (id, vol) in volume_requests {
make_request_async(Request::set_volume(vol, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.volume_dragged = false;
ui_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
if let Some(volume) = self.app_state.mic_volume.take_pending() {
make_request_async(Request::set_mic_volume(volume));
self.remember_volume(|config| config.default_mic_volume = Some(volume));
}
if self.app_state.volume_dragged {
make_request_async(Request::set_volume(
self.app_state.volume_slider_value,
None,
));
if let Some(multiplier) = self.app_state.volume_multiplier.take_pending() {
make_request_async(Request::set_volume_multiplier(multiplier));
self.app_state.volume_dragged = false;
self.app_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
if self.config.save_volume {
let mut daemon_config = get_daemon_config();
daemon_config.default_volume = Some(self.app_state.volume_slider_value);
daemon_config.save_to_file().ok();
if self.config.save_volume_multiplier
&& let Ok(mut daemon_config) = get_daemon_config()
{
daemon_config.default_volume_multiplier = Some(multiplier);
update_daemon_config(&daemon_config).ok();
}
}
+183 -17
View File
@@ -10,7 +10,7 @@ use pwsp_lib::types::{
gui::{AppState, AudioPlayerState},
};
use rust_i18n::t;
use std::{cmp::Ordering, path::Path, path::PathBuf};
use std::{cmp::Ordering, collections::HashSet, path::Path, path::PathBuf};
pub(crate) enum FileAction {
Play(PathBuf, bool),
@@ -195,7 +195,7 @@ impl SoundpadGui {
.sort_order = order;
self.config.save_to_file().ok();
self.app_state.dir_cache.remove(path);
self.open_dir(path);
self.open_dir(path, Some(ui.ctx().clone()), false);
}
});
});
@@ -203,7 +203,7 @@ impl SoundpadGui {
self.app_state.dirs = dirs;
if let Some(path) = dir_to_open {
self.open_dir(&path);
self.open_dir(&path, Some(ui.ctx().clone()), false);
}
ui.horizontal(|ui| {
@@ -227,12 +227,41 @@ impl SoundpadGui {
fn draw_files_search_field(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let is_scanning = self
.app_state
.current_dir
.as_ref()
.is_some_and(|d| self.app_state.scanning_dirs.lock().unwrap().contains(d));
let spinner_width = if is_scanning { 20.0 } else { 0.0 };
let refresh_button = Button::new(ICON_REFRESH).frame(false);
let refresh_width = 24.0;
let search_field_response = ui.add_sized(
[ui.available_width(), 22.0],
[
f32::max(
0.0,
ui.available_width() - spinner_width - refresh_width - 8.0,
),
22.0,
],
TextEdit::singleline(&mut self.app_state.search_query)
.hint_text(t!("gui.search_placeholder")),
);
if is_scanning {
ui.spinner();
}
let refresh_response = ui
.add_sized([refresh_width, 22.0], refresh_button)
.on_hover_text("Refresh");
if refresh_response.clicked()
&& let Some(dir) = self.app_state.current_dir.clone()
{
self.open_dir(&dir, Some(ui.ctx().clone()), true);
}
if self.app_state.force_focus_search {
search_field_response.request_focus();
self.app_state.force_focus_search = false;
@@ -247,9 +276,54 @@ impl SoundpadGui {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
let finished = {
let mut scans = self.app_state.finished_scans.lock().unwrap();
std::mem::take(&mut *scans)
};
for (top_dir, all_files, dir_updates) in finished {
self.app_state
.recursive_files_cache
.insert(top_dir, all_files);
self.app_state.dir_cache.extend(dir_updates);
}
ui.vertical(|ui| {
let mut actions = Vec::new();
let files = self.get_filtered_files();
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
let mut matching_dirs = std::collections::HashSet::new();
let mut matching_files = std::collections::HashSet::new();
if !search_query.is_empty()
&& let Some(current_dir) = &self.app_state.current_dir
&& let Some(all_files) = self.app_state.recursive_files_cache.get(current_dir)
{
for file in all_files {
if let Ok(rel_path) = file.strip_prefix(current_dir) {
let rel_path_str =
rel_path.to_string_lossy().to_lowercase().replace("\\", "/");
if rel_path_str.contains(search_query) {
matching_files.insert(file.clone());
let mut p = file.parent();
while let Some(parent) = p {
if parent == current_dir {
break;
}
matching_dirs.insert(parent.to_path_buf());
p = parent.parent();
}
}
}
}
}
let max_results = 300;
let is_truncated = !search_query.is_empty() && matching_files.len() > max_results;
let files = self.get_filtered_files(&matching_dirs, &matching_files);
let mut rendered_count = 0;
for entry_path in files {
Self::draw_tree_node(
ui,
@@ -258,6 +332,22 @@ impl SoundpadGui {
&mut self.app_state,
&self.audio_player_state,
&mut actions,
&matching_dirs,
&matching_files,
search_query,
&mut rendered_count,
max_results,
);
}
if is_truncated {
ui.add_space(8.0);
let text = t!(
"gui.search_truncated_warning",
count = matching_files.len() - max_results
);
ui.label(
egui::RichText::new(text).color(egui::Color32::from_rgb(200, 150, 50)),
);
}
@@ -286,6 +376,7 @@ impl SoundpadGui {
});
}
#[allow(clippy::too_many_arguments)]
fn draw_tree_node_dir(
ui: &mut Ui,
path: std::path::PathBuf,
@@ -293,15 +384,39 @@ impl SoundpadGui {
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
matching_dirs: &std::collections::HashSet<PathBuf>,
matching_files: &std::collections::HashSet<PathBuf>,
search_query: &str,
rendered_count: &mut usize,
max_results: usize,
) {
if *rendered_count >= max_results && !search_query.is_empty() {
return;
}
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
CollapsingHeader::new(dir_name)
.id_salt(&path)
.show(ui, |ui| {
let id_salt = if search_query.is_empty() {
format!("normal_{:?}", path)
} else {
format!("search_{:?}", path)
};
let mut header = CollapsingHeader::new(dir_name).id_salt(id_salt);
let is_cached = app_state
.current_dir
.as_ref()
.is_some_and(|d| app_state.recursive_files_cache.contains_key(d));
if !search_query.is_empty() && is_cached {
header = header.default_open(true);
}
header.show(ui, |ui| {
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
cached.clone()
} else {
@@ -339,21 +454,40 @@ impl SoundpadGui {
read
};
let search_query = app_state.search_query.to_lowercase();
let search_query = search_query.trim();
for child in children {
if !child.is_dir() && !search_query.is_empty() {
if *rendered_count >= max_results && !search_query.is_empty() {
break;
}
if !search_query.is_empty() && is_cached {
if child.is_dir() && !matching_dirs.contains(&child) {
continue;
}
if !child.is_dir() && !matching_files.contains(&child) {
continue;
}
} else if !search_query.is_empty() && !child.is_dir() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
.to_lowercase();
if !file_name.contains(search_query) {
continue;
}
}
Self::draw_tree_node(ui, child, config, app_state, audio_player_state, actions);
Self::draw_tree_node(
ui,
child,
config,
app_state,
audio_player_state,
actions,
matching_dirs,
matching_files,
search_query,
rendered_count,
max_results,
);
}
});
}
@@ -364,7 +498,10 @@ impl SoundpadGui {
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
rendered_count: &mut usize,
) {
*rendered_count += 1;
let file_name = path
.file_name()
.unwrap_or_default()
@@ -500,6 +637,7 @@ impl SoundpadGui {
});
}
#[allow(clippy::too_many_arguments)]
fn draw_tree_node(
ui: &mut Ui,
path: std::path::PathBuf,
@@ -507,11 +645,39 @@ impl SoundpadGui {
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
matching_dirs: &HashSet<PathBuf>,
matching_files: &HashSet<PathBuf>,
search_query: &str,
rendered_count: &mut usize,
max_results: usize,
) {
if *rendered_count >= max_results && !search_query.is_empty() {
return;
}
if path.is_dir() {
Self::draw_tree_node_dir(ui, path, config, app_state, audio_player_state, actions);
Self::draw_tree_node_dir(
ui,
path,
config,
app_state,
audio_player_state,
actions,
matching_dirs,
matching_files,
search_query,
rendered_count,
max_results,
);
} else {
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
Self::draw_tree_node_file(
ui,
path,
app_state,
audio_player_state,
actions,
rendered_count,
);
}
}
}
+63 -49
View File
@@ -1,80 +1,93 @@
use crate::gui::SoundpadGui;
use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2};
use egui::{AtomExt, Button, Label, RichText, Slider, Ui, Vec2};
use egui_material_icons::icons::*;
use pwsp_lib::types::gui::SliderLatch;
use rust_i18n::t;
use std::time::Instant;
/// Masters go past 100% on purpose: amplifying the mic feed without deafening yourself is
/// the point of splitting the two paths.
const MAX_MASTER_VOLUME: f32 = 2.0;
const VOLUME_SLIDER_WIDTH: f32 = 90.0;
const ICON_SIZE: f32 = 18.0;
/// Fixes the row height before anything is placed in it.
///
/// A horizontal layout centres each widget against the row height known at the time, so a
/// row that grows while being filled leaves whatever was added first sitting too high.
const FOOTER_ROW_HEIGHT: f32 = 24.0;
impl SoundpadGui {
pub fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
self.draw_mic_selection(ui);
self.draw_master_volume(ui);
ui.set_min_height(FOOTER_ROW_HEIGHT);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
self.draw_monitoring_volume(ui);
self.draw_mic_volume(ui);
// Right-aligns the icon buttons. Clamped because a negative value pushes them
// out of view instead of merely crowding them.
let spacer = ui.available_width() - ICON_SIZE * 2.0 - ui.spacing().item_spacing.x * 2.0;
ui.add_space(spacer.max(0.0));
self.draw_hotkeys_button(ui);
self.draw_settings_button(ui);
});
}
fn draw_mic_selection(&mut self, ui: &mut Ui) {
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
fn draw_monitoring_volume(&mut self, ui: &mut Ui) {
let volume = self.audio_player_state.monitoring_volume;
let icon = Self::get_volume_icon(volume);
ui.add_sized(
[ICON_SIZE, FOOTER_ROW_HEIGHT],
Label::new(RichText::new(icon).size(ICON_SIZE)),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
}
fn draw_master_volume(&mut self, ui: &mut Ui) {
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
"{}: {:.0}%",
t!("gui.monitoring_volume"),
volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
Self::draw_volume_slider(ui, &mut self.app_state.monitoring_volume, volume);
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
fn draw_mic_volume(&mut self, ui: &mut Ui) {
let volume = self.audio_player_state.mic_volume;
let icon = if volume <= 0.0 {
ICON_MIC_OFF.codepoint
} else {
ICON_MIC.codepoint
};
ui.add_sized(
[ICON_SIZE, FOOTER_ROW_HEIGHT],
Label::new(RichText::new(icon).size(ICON_SIZE)),
)
.on_hover_text(format!("{}: {:.0}%", t!("gui.mic_volume"), volume * 100.0));
Self::draw_volume_slider(ui, &mut self.app_state.mic_volume, volume);
}
fn draw_volume_slider(ui: &mut Ui, latch: &mut SliderLatch, daemon_value: f32) {
latch.sync(daemon_value);
// A Slider draws its rail at spacing().slider_width regardless of what add_sized
// allocates, so both need the same number or the widget overruns its space.
ui.spacing_mut().slider_width = VOLUME_SLIDER_WIDTH;
let slider = Slider::new(&mut latch.value, 0.0..=MAX_MASTER_VOLUME)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
if ui
.add_sized([VOLUME_SLIDER_WIDTH, FOOTER_ROW_HEIGHT], slider)
.drag_stopped()
{
latch.dragged = true;
}
}
fn draw_hotkeys_button(&mut self, ui: &mut Ui) {
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
let hotkeys_button_response = ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
@@ -84,7 +97,8 @@ impl SoundpadGui {
fn draw_settings_button(&mut self, ui: &mut Ui) {
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
let settings_button_response =
ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
+6 -24
View File
@@ -3,7 +3,6 @@ use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui};
use egui_material_icons::icons::*;
use pwsp_lib::types::{audio_player::TrackInfo, gui::AppState};
use pwsp_lib::utils::gui::format_time_pair;
use std::time::Instant;
pub(crate) enum TrackAction {
Pause(u32),
@@ -96,7 +95,7 @@ impl SoundpadGui {
default_slider_width: f32,
) {
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
let position_slider = Slider::new(&mut ui_state.position.value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
@@ -107,7 +106,7 @@ impl SoundpadGui {
ui.spacing_mut().slider_width = position_slider_width;
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
ui_state.position_dragged = true;
ui_state.position.dragged = true;
}
let time_label =
@@ -126,7 +125,7 @@ impl SoundpadGui {
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
let volume_slider = Slider::new(&mut ui_state.volume.value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
@@ -134,7 +133,7 @@ impl SoundpadGui {
ui.spacing_mut().item_spacing.x = 0.0;
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
ui_state.volume_dragged = true;
ui_state.volume.dragged = true;
}
}
@@ -154,25 +153,8 @@ impl SoundpadGui {
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
ui_state.position.sync(track.position);
ui_state.volume.sync(track.volume);
let mut action = None;
+4
View File
@@ -51,5 +51,9 @@ mod tests {
SoundpadGui::get_volume_icon(0.5),
ICON_VOLUME_DOWN.codepoint
);
// Masters go past 100%, which must not fall off the top of the ladder.
assert_eq!(SoundpadGui::get_volume_icon(1.0), ICON_VOLUME_UP.codepoint);
assert_eq!(SoundpadGui::get_volume_icon(2.0), ICON_VOLUME_UP.codepoint);
}
}
+119 -2
View File
@@ -1,5 +1,5 @@
use crate::gui::SoundpadGui;
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Ui};
use crate::{gui::SoundpadGui, locale};
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Slider, Ui};
use egui_material_icons::icons::ICON_ARROW_BACK;
use pwsp_lib::types::config::PreferredTheme;
use rust_i18n::t;
@@ -34,6 +34,10 @@ impl SoundpadGui {
&mut self.config.save_volume,
t!("gui.settings.remember_volume"),
);
let save_volume_multiplier_response = ui.checkbox(
&mut self.config.save_volume_multiplier,
t!("gui.settings.remember_volume_multiplier"),
);
let save_input_response =
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
let save_scale_response = ui.checkbox(
@@ -46,6 +50,7 @@ impl SoundpadGui {
);
if save_volume_response.changed()
|| save_volume_multiplier_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
@@ -57,6 +62,10 @@ impl SoundpadGui {
ui.separator();
// ---------- Selectors -----------
self.draw_mic_selection(ui);
self.draw_output_selection(ui);
self.draw_language_selection(ui);
let mut selected_theme = self.config.preferred_theme.clone();
ComboBox::from_label(t!("gui.settings.theme.label"))
.selected_text(match self.config.preferred_theme {
@@ -88,6 +97,28 @@ impl SoundpadGui {
}
// --------------------------------
ui.separator();
// ----------- Sliders ------------
// Volume multiplier
self.app_state
.volume_multiplier
.sync(self.audio_player_state.volume_multiplier);
ui.horizontal(|ui| {
let slider = Slider::new(&mut self.app_state.volume_multiplier.value, 0.01..=3.0);
let response = ui.add(slider);
ui.label(t!("gui.settings.volume_multiplier"));
if response.changed() {
// This condition is required to avoid spamming requests while dragging, but to allow changing the value via TextEdit
if !response.dragged() || (response.dragged() && response.drag_stopped()) {
self.app_state.volume_multiplier.dragged = true;
}
}
});
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(t!(
"gui.settings.version",
@@ -96,4 +127,90 @@ impl SoundpadGui {
});
});
}
fn draw_mic_selection(&mut self, ui: &mut Ui) {
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
}
fn draw_output_selection(&mut self, ui: &mut Ui) {
let outputs = &self.audio_player_state.all_outputs_sorted;
let mut selected_output = self.audio_player_state.current_output.to_owned();
let prev_output = selected_output.to_owned();
// An empty selection means no device is pinned.
let selected_text = self
.audio_player_state
.all_outputs
.get(&selected_output)
.cloned()
.unwrap_or_else(|| t!("gui.default_output").to_string());
ComboBox::from_label(t!("gui.choose_output_select"))
.height(30.0)
.selected_text(selected_text)
.show_ui(ui, |ui| {
// Listed first so pinning a device stays undoable.
ui.selectable_value(
&mut selected_output,
String::new(),
t!("gui.default_output"),
);
for (name, nick) in outputs {
ui.selectable_value(&mut selected_output, name.clone(), nick);
}
});
if selected_output != prev_output {
self.set_output(selected_output);
}
}
fn draw_language_selection(&mut self, ui: &mut Ui) {
let mut selected = self.config.forced_lang.clone();
let previous = selected.clone();
let selected_text = match &selected {
Some(code) => locale::display_name(code).to_string(),
None => t!("gui.settings.language.system").to_string(),
};
ComboBox::from_label(t!("gui.settings.language.label"))
.height(30.0)
.selected_text(selected_text)
.show_ui(ui, |ui| {
// Listed first so picking a language stays undoable.
ui.selectable_value(&mut selected, None, t!("gui.settings.language.system"));
for code in locale::available() {
let name = locale::display_name(&code);
ui.selectable_value(&mut selected, Some(code.clone()), name);
}
});
if selected != previous {
self.config.forced_lang = selected;
self.config.save_to_file().ok();
locale::apply(&self.config);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
use pwsp_lib::types::config::GuiConfig;
/// Applies the UI language: the one pinned in the config, or the system's when none is.
///
/// Safe to call at any time — `t!` reads the locale on every lookup, so the next frame
/// already renders in the new language.
pub fn apply(config: &GuiConfig) {
let locale = config.forced_lang.clone().unwrap_or_else(system_locale);
rust_i18n::set_locale(&locale);
}
fn system_locale() -> String {
sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))
}
/// Locales shipped under `locales/`, in a stable order for the menu.
pub fn available() -> Vec<String> {
let mut locales: Vec<String> = rust_i18n::available_locales!()
.into_iter()
.map(|locale| locale.to_string())
.collect();
locales.sort_unstable();
locales
}
/// How a language names itself, which is how it should appear in a language picker
/// regardless of the language the rest of the UI is in. An unnamed locale falls back to
/// its code, so a newly shipped translation is still selectable.
pub fn display_name(code: &str) -> &str {
match code {
"en" => "English",
"ru" => "Русский",
"es" => "Español",
"fr" => "Français",
"zh" => "中文",
"ar" => "العربية",
"kz" => "Қазақша",
"he" => "עברית",
"pt-BR" => "Português (Brasil)",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_locale_has_a_native_name() {
for code in available() {
assert_ne!(
display_name(&code),
code,
"locale {code} falls back to its code in the picker"
);
}
}
}
+28 -12
View File
@@ -1,16 +1,20 @@
mod gui;
mod locale;
use anyhow::{Context, Result};
use pwsp_lib::utils::gui::ensure_pwsp_audio_dir;
use anyhow::{Context, Result, anyhow};
use pwsp_lib::utils::gui::{ensure_pwsp_audio_dir, get_gui_config};
use rust_i18n::i18n;
use std::{env, path::PathBuf};
use std::{
env,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
i18n!("locales", fallback = "en");
#[tokio::main]
async fn main() -> Result<()> {
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
rust_i18n::set_locale(&locale);
locale::apply(&get_gui_config());
let args = env::args().skip(1).collect::<Vec<String>>();
@@ -31,19 +35,31 @@ async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
let target_url = uri
.strip_prefix(prefix)
.ok_or_else(|| anyhow::anyhow!("URI does not containt an expected prefix: {}", prefix))?;
.ok_or_else(|| anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = target_url
.split('/')
.next_back()
.unwrap_or("downloaded_audio.mp3");
let file_name_encoded = match target_url.split('/').next_back() {
Some(path) => path.to_string(),
None => {
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went back")
.as_nanos();
format!("downloaded_audio_{}.mp3", id)
}
};
let file_name = percent_encoding::percent_decode_str(file_name_encoded)
let file_name = percent_encoding::percent_decode_str(&file_name_encoded.clone())
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let save_path = ensure_pwsp_audio_dir().join(file_name);
let normalized_file_name = file_name.replace('\\', "/");
let sanitized_file_name = Path::new(&normalized_file_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("downloaded_audio.mp3");
let save_path = ensure_pwsp_audio_dir()?.join(sanitized_file_name);
let response = reqwest::get(target_url)
.await?
+494 -168
View File
@@ -1,21 +1,34 @@
use crate::{
types::pipewire::DeviceType,
types::pipewire::{AudioDevice, DeviceType},
utils::{
daemon::get_daemon_config,
pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic},
daemon::with_daemon_config,
pipewire::{
PwTerminator, create_link, ensure_route, get_all_devices, get_device, get_device_by_id,
get_sink,
},
},
};
use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
error::Error,
collections::{HashMap, HashSet},
fs,
io::BufReader,
path::{Path, PathBuf},
time::Duration,
};
const VIRTUAL_MIC_NAME: &str = "pwsp-virtual-mic";
/// Streams are re-opened on the first play after an idle period, so this poll sits in the
/// path between pressing a key and hearing the sound. The node normally shows up within a
/// poll or two; the attempt count only bounds the pathological case.
const NODE_DISCOVERY_ATTEMPTS: u32 = 200;
const NODE_DISCOVERY_INTERVAL: Duration = Duration::from_millis(5);
type FileDecoder = Decoder<BufReader<fs::File>>;
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
pub enum PlayerState {
#[default]
@@ -39,47 +52,136 @@ pub struct TrackInfo {
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub current_output: String,
pub all_outputs: HashMap<String, String>,
}
/// Which of the two independent output paths a volume applies to.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum VolumeTarget {
/// What the user hears locally.
Monitoring,
/// What is fed into the virtual microphone, i.e. what everybody else hears.
Mic,
}
/// The two rodio players backing a single track, one per output path.
///
/// Every playback control has to reach both, so they are wrapped rather than fanned out
/// by hand at each call site.
pub struct PlayerPair {
pub monitoring: Player,
pub mic: Option<Player>,
}
impl PlayerPair {
pub fn for_each(&self, f: impl Fn(&Player)) {
f(&self.monitoring);
if let Some(mic) = &self.mic {
f(mic);
}
}
/// The player that answers queries about position and paused state for the pair.
pub fn primary(&self) -> &Player {
&self.monitoring
}
/// True once every player of the pair has run out of samples.
pub fn empty(&self) -> bool {
self.monitoring.empty() && self.mic.as_ref().is_none_or(|mic| mic.empty())
}
}
pub struct PlayingSound {
pub id: u32,
pub sink: Player,
pub players: PlayerPair,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
pub volume: f32,
}
/// Final linear gain handed to a rodio `Player`.
///
/// Values above `1.0` are allowed on purpose; anything non-finite or negative collapses to
/// silence rather than reaching the mixer.
pub fn effective_gain(master: f32, track: f32, multiplier: f32) -> f32 {
let gain = master * track * multiplier;
if gain.is_finite() && gain > 0.0 {
gain
} else {
0.0
}
}
pub struct AudioPlayer {
stream_handle: Option<MixerDeviceSink>,
monitoring_stream: Option<MixerDeviceSink>,
mic_stream: Option<MixerDeviceSink>,
/// PipeWire nodes backing the two streams, once discovered.
monitoring_node: Option<AudioDevice>,
mic_node: Option<AudioDevice>,
/// Links we created ourselves; dropping them tears the routes down.
monitoring_route: Option<PwTerminator>,
mic_route: Option<PwTerminator>,
input_link_sender: Option<PwTerminator>,
pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32,
input_link_sender: Option<PwTerminator>,
player_link_sender: Option<PwTerminator>,
pub input_device_name: Option<String>,
/// `None` pins nothing, leaving the monitoring stream to be routed like any other
/// application's.
pub output_device_name: Option<String>,
pub volume: f32, // Master volume
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
}
impl AudioPlayer {
pub async fn new() -> Result<Self> {
let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
let (
default_input_name,
default_output_name,
default_monitoring_volume,
default_mic_volume,
default_volume_multiplier,
) = with_daemon_config(|c| {
(
c.default_input_name.clone(),
c.default_output_name.clone(),
c.default_monitoring_volume.unwrap_or(1.0),
c.default_mic_volume.unwrap_or(1.0),
c.default_volume_multiplier.unwrap_or(1.0),
)
});
let mut audio_player = AudioPlayer {
stream_handle: None,
monitoring_stream: None,
mic_stream: None,
monitoring_node: None,
mic_node: None,
monitoring_route: None,
mic_route: None,
input_link_sender: None,
tracks: HashMap::new(),
next_id: 1,
input_link_sender: None,
player_link_sender: None,
input_device_name: daemon_config.default_input_name.clone(),
input_device_name: default_input_name,
output_device_name: default_output_name,
volume: default_volume,
monitoring_volume: default_monitoring_volume,
mic_volume: default_mic_volume,
volume_multiplier: default_volume_multiplier,
};
if audio_player.input_device_name.is_some() {
@@ -89,22 +191,83 @@ impl AudioPlayer {
Ok(audio_player)
}
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> {
if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
self.stream_handle = Some(sink);
}
self.stream_handle
.as_ref()
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
/// Opens both output streams and routes them, if that has not happened yet.
///
/// Streams are opened one at a time: node discovery below tells them apart by diffing
/// the graph around each open, which only works if the opens do not overlap.
async fn ensure_streams(&mut self) -> Result<()> {
if self.monitoring_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.monitoring_stream = Some(stream);
self.monitoring_node = node;
}
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
if self.mic_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.mic_stream = Some(stream);
self.mic_node = node;
}
self.ensure_routes().await;
Ok(())
}
/// Closes both output streams once nothing is playing.
///
/// This is what lets a laptop suspend: an open stream keeps the audio device busy and
/// blocks sleep. Routing is rebuilt on the next `play()`, since re-opening the streams
/// mints new node ids.
fn drop_streams(&mut self) {
if self.monitoring_stream.is_none() && self.mic_stream.is_none() {
return;
}
self.monitoring_stream = None;
self.mic_stream = None;
// The nodes and our links die with the streams, so none of this may outlive them.
self.monitoring_node = None;
self.mic_node = None;
self.monitoring_route = None;
self.mic_route = None;
}
/// Re-asserts both routes. Idempotent, so it can run on every device-check tick.
async fn ensure_routes(&mut self) {
if let Some(node) = self.mic_node.clone() {
match self.route(&node, get_device(VIRTUAL_MIC_NAME).await).await {
Ok(Some(terminator)) => self.mic_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!("Failed to route mic stream to virtual mic: {}", err),
}
}
// With nothing pinned the monitoring stream is left alone: picking a target
// ourselves would mean re-implementing the session manager's policy, where the
// default sink is only a fallback that a per-stream target overrides.
if let Some(name) = self.output_device_name.clone()
&& let Some(node) = self.monitoring_node.clone()
{
match self.route(&node, get_sink(&name).await).await {
Ok(Some(terminator)) => self.monitoring_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!(
"Failed to route monitoring stream to output device {}: {}",
name, err
),
}
}
}
/// Re-reads the source node before linking, so a route survives the node's ports
/// being rediscovered.
async fn route(
&self,
source: &AudioDevice,
target: Result<AudioDevice>,
) -> Result<Option<PwTerminator>> {
let source = get_device_by_id(source.id).await?;
ensure_route(&source, &target?).await
}
fn abort_link_thread(&mut self) {
@@ -114,27 +277,6 @@ impl AudioPlayer {
}
}
fn abort_player_link_thread(&mut self) {
if self.player_link_sender.is_some() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
}
}
async fn link_player(&mut self) -> Result<()> {
if self.player_link_sender.is_some() {
return Ok(());
}
match link_player_to_virtual_mic().await {
Ok(sender) => {
self.player_link_sender = Some(sender);
Ok(())
}
Err(_) => Ok(()),
}
}
async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread();
@@ -155,7 +297,7 @@ impl AudioPlayer {
}
let daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
if let Ok(device) = get_device(VIRTUAL_MIC_NAME).await {
daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
@@ -163,19 +305,19 @@ impl AudioPlayer {
}
let Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl");
eprintln!("Failed to get input device output_fl");
return Ok(());
};
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr");
eprintln!("Failed to get input device output_fr");
return Ok(());
};
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl");
eprintln!("Failed to get pwsp-virtual-mic input_fl");
return Ok(());
};
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr");
eprintln!("Failed to get pwsp-virtual-mic input_fr");
return Ok(());
};
@@ -185,25 +327,21 @@ impl AudioPlayer {
}
pub fn pause(&mut self, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.pause();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.pause();
}
}
self.for_selected(id, |sound| sound.players.for_each(|p| p.pause()));
}
pub fn resume(&mut self, id: Option<u32>) {
self.for_selected(id, |sound| sound.players.for_each(|p| p.play()));
}
fn for_selected(&mut self, id: Option<u32>, f: impl Fn(&mut PlayingSound)) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.play();
f(sound);
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.play();
f(sound);
}
}
}
@@ -215,7 +353,7 @@ impl AudioPlayer {
self.tracks.clear();
}
if self.tracks.is_empty() {
self.drop_stream();
self.drop_streams();
}
}
@@ -223,7 +361,9 @@ impl AudioPlayer {
if self.tracks.is_empty() {
return false;
}
self.tracks.values().all(|s| s.sink.is_paused())
self.tracks
.values()
.all(|s| s.players.primary().is_paused())
}
pub fn get_state(&self) -> PlayerState {
@@ -234,7 +374,7 @@ impl AudioPlayer {
if self
.tracks
.values()
.any(|s| !s.sink.is_paused() && !s.sink.empty())
.any(|s| !s.players.primary().is_paused() && !s.players.primary().empty())
{
return PlayerState::Playing;
}
@@ -246,55 +386,95 @@ impl AudioPlayer {
PlayerState::Stopped
}
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
Some(sound.sink.volume())
} else {
None
}
} else {
Some(self.volume)
pub fn get_volume(&self, id: Option<u32>) -> Option<f32> {
match id {
Some(id) => self.tracks.get(&id).map(|sound| sound.volume),
None => Some(self.monitoring_volume),
}
}
/// Pushes the current master/track/multiplier state onto every live player.
///
/// Single source of truth for the gain math — every setter below funnels through it.
fn reapply_volumes(&mut self) {
for sound in self.tracks.values() {
sound.players.monitoring.set_volume(effective_gain(
self.monitoring_volume,
sound.volume,
self.volume_multiplier,
));
if let Some(mic) = &sound.players.mic {
mic.set_volume(effective_gain(
self.mic_volume,
sound.volume,
self.volume_multiplier,
));
}
}
}
pub fn set_master_volume(&mut self, volume: f32, target: VolumeTarget) {
match target {
VolumeTarget::Monitoring => self.monitoring_volume = volume,
VolumeTarget::Mic => self.mic_volume = volume,
}
self.reapply_volumes();
}
pub fn get_master_volume(&self, target: VolumeTarget) -> f32 {
match target {
VolumeTarget::Monitoring => self.monitoring_volume,
VolumeTarget::Mic => self.mic_volume,
}
}
pub fn set_volume_multiplier(&mut self, multiplier: f32) {
self.volume_multiplier = multiplier;
self.reapply_volumes();
}
/// Sets a single track's volume, or — without an id — moves both masters at once,
/// which is the "make everything quieter" shortcut.
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.volume = volume;
sound.sink.set_volume(self.volume * volume);
}
} else {
self.volume = volume;
for sound in self.tracks.values_mut() {
sound.sink.set_volume(self.volume * sound.volume);
}
self.monitoring_volume = volume;
self.mic_volume = volume;
}
self.reapply_volumes();
}
pub fn get_position(&self, id: Option<u32>) -> f32 {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.sink.get_pos().as_secs_f32();
return sound.players.primary().get_pos().as_secs_f32();
}
} else if let Some(sound) = self.tracks.values().last() {
// Fallback to last added track if no ID
return sound.sink.get_pos().as_secs_f32();
return sound.players.primary().get_pos().as_secs_f32();
}
0.0
}
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
let position = if position < 0.0 { 0.0 } else { position };
let position = Duration::from_secs_f32(position);
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.try_seek(Duration::from_secs_f32(position))?;
sound.players.monitoring.try_seek(position)?;
if let Some(mic) = &sound.players.mic {
mic.try_seek(position).ok();
}
}
} else {
// Seek all? Or last? Let's seek all for now if no ID provided
for sound in self.tracks.values_mut() {
sound.sink.try_seek(Duration::from_secs_f32(position)).ok();
sound.players.for_each(|p| {
p.try_seek(position).ok();
});
}
}
Ok(())
@@ -312,74 +492,65 @@ impl AudioPlayer {
}
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
let path_buf = file_path.to_path_buf();
// One decoder per output path: the two streams run on different device clocks,
// so they cannot share a source anyway, and rodio's `Buffered` (the only shareable
// source) does not support seeking.
let (monitoring_source, mic_source) = tokio::try_join!(
decode(file_path.to_path_buf()),
decode(file_path.to_path_buf()),
)?;
let decoder_result =
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !path_buf.exists() {
return Err(format!("File does not exist: {}", path_buf.display()).into());
}
let file = fs::File::open(&path_buf)?;
let decoder = Decoder::try_from(file)
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
Ok(decoder)
})
.await?;
match decoder_result {
Ok(source) => {
if !concurrent {
self.tracks.clear();
}
self.ensure_stream()?;
self.link_player().await.ok();
self.ensure_streams().await?;
let duration = monitoring_source.total_duration().map(|d| d.as_secs_f32());
let monitoring_mixer = self
.monitoring_stream
.as_ref()
.ok_or_else(|| anyhow!("monitoring stream is unexpectedly missing"))?
.mixer();
let monitoring = Player::connect_new(monitoring_mixer);
monitoring.set_volume(effective_gain(
self.monitoring_volume,
1.0,
self.volume_multiplier,
));
monitoring.append(monitoring_source);
monitoring.play();
// A missing mic stream degrades to monitoring-only playback rather than failing.
let mic = self.mic_stream.as_ref().map(|stream| {
let player = Player::connect_new(stream.mixer());
player.set_volume(effective_gain(self.mic_volume, 1.0, self.volume_multiplier));
player.append(mic_source);
player.play();
player
});
let id = self.next_id;
self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32());
let mixer = self
.stream_handle
.as_ref()
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
.mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source);
sink.play();
let sound = PlayingSound {
self.tracks.insert(
id,
sink,
PlayingSound {
id,
players: PlayerPair { monitoring, mic },
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
};
self.tracks.insert(id, sound);
},
);
Ok(id)
}
Err(err) => Err(anyhow!(err)),
}
}
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.looped = enabled;
}
} else {
// Set loop for all? Or just last?
// Let's set for all.
for sound in self.tracks.values_mut() {
sound.looped = enabled;
}
}
self.for_selected(id, |sound| sound.looped = enabled);
}
pub fn get_tracks(&self) -> Vec<TrackInfo> {
@@ -390,10 +561,10 @@ impl AudioPlayer {
id: sound.id,
path: sound.path.clone(),
duration: sound.duration,
position: sound.sink.get_pos().as_secs_f32(),
position: sound.players.primary().get_pos().as_secs_f32(),
volume: sound.volume,
looped: sound.looped,
paused: sound.sink.is_paused(),
paused: sound.players.primary().is_paused(),
})
.collect();
tracks.sort_by_key(|t| t.id);
@@ -418,52 +589,56 @@ impl AudioPlayer {
}
}
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
if self.monitoring_stream.is_some() || self.mic_stream.is_some() {
self.ensure_routes().await;
}
}
// Handle looped sounds
let mut restarts = vec![];
self.restart_looped_tracks().await;
for (id, sound) in &self.tracks {
if sound.sink.empty() && sound.looped {
restarts.push(*id);
self.tracks
.retain(|_, sound| !sound.players.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_streams();
}
}
async fn restart_looped_tracks(&mut self) {
let restarts: Vec<u32> = self
.tracks
.iter()
.filter(|(_, sound)| sound.looped && sound.players.empty())
.map(|(id, _)| *id)
.collect();
let mut restart_futures = vec![];
for id in restarts {
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path)
&& let Ok(source) = Decoder::try_from(file)
{
return Some((id, source));
}
None
restart_futures.push(async move {
tokio::try_join!(decode(path.clone()), decode(path))
.ok()
.map(|(monitoring, mic)| (id, monitoring, mic))
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(res) = handle.await
&& let Some((id, source)) = res
for future in restart_futures {
if let Some((id, monitoring_source, mic_source)) = future.await
&& let Some(sound) = self.tracks.get_mut(&id)
{
sound.sink.append(source);
sound.sink.play();
if sound.players.monitoring.empty() {
sound.players.monitoring.append(monitoring_source);
sound.players.monitoring.play();
}
if let Some(mic) = &sound.players.mic
&& mic.empty()
{
mic.append(mic_source);
mic.play();
}
}
self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_stream();
}
}
@@ -480,4 +655,155 @@ impl AudioPlayer {
Ok(())
}
pub async fn set_current_output_device(&mut self, name: &str) -> Result<()> {
// Fails early with a useful message if the name does not name a sink.
get_sink(name).await?;
self.output_device_name = Some(name.to_string());
self.monitoring_route = None;
self.ensure_routes().await;
Ok(())
}
/// Stops pinning an output device.
///
/// The existing link is deliberately left in place: tearing it down would strand the
/// node with no output at all, since autoconnect only runs when a node first appears.
/// Streams close as soon as playback stops, so the next sound opens a fresh node that
/// gets routed normally.
pub fn clear_current_output_device(&mut self) {
self.output_device_name = None;
}
}
async fn decode(path: PathBuf) -> Result<FileDecoder> {
tokio::task::spawn_blocking(move || {
if !path.exists() {
return Err(anyhow!("File does not exist: {}", path.display()));
}
let file = fs::File::open(&path)?;
Decoder::try_from(file).map_err(|e| anyhow!(e))
})
.await?
}
/// True for the `Stream/Output/Audio` nodes that belong to us.
///
/// The guard matters: node discovery below hands the result to link pruning, and pruning
/// a stranger's node would silence another application.
fn is_own_stream_node(device: &AudioDevice) -> bool {
let is_pwsp = |s: &str| s.to_ascii_lowercase().contains("pwsp");
(is_pwsp(&device.name) || is_pwsp(&device.nick))
&& device.output_fl.is_some()
&& device.output_fr.is_some()
}
async fn own_stream_node_ids() -> HashSet<u32> {
match get_all_devices().await {
Ok(devices) => devices
.outputs
.iter()
.filter(|d| is_own_stream_node(d))
.map(|d| d.id)
.collect(),
Err(_) => HashSet::new(),
}
}
/// Opens one rodio output stream and works out which PipeWire node it produced.
///
/// Identification is a before/after diff of our own stream nodes rather than an index
/// into a sorted list, so it does not depend on enumeration order. Callers must open
/// streams one at a time for the diff to stay unambiguous.
///
/// A stream that cannot be matched to a node yields `None`: playback still works, only
/// explicit routing is skipped.
async fn open_stream_and_identify() -> Result<(MixerDeviceSink, Option<AudioDevice>)> {
let before = own_stream_node_ids().await;
let mut stream = DeviceSinkBuilder::open_default_sink()?;
stream.log_on_drop(false);
// Checked before the first sleep: the PCM is already open by the time open_stream
// returns, so the node is often registered and this costs nothing.
for attempt in 0..NODE_DISCOVERY_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(NODE_DISCOVERY_INTERVAL).await;
}
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(_) => continue,
};
if let Some(node) = devices
.outputs
.into_iter()
.find(|d| !before.contains(&d.id) && is_own_stream_node(d))
{
return Ok((stream, Some(node)));
}
}
eprintln!("Timed out waiting for the new PipeWire node, routing will be skipped");
Ok((stream, None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_effective_gain() {
assert_eq!(effective_gain(1.0, 1.0, 1.0), 1.0);
assert_eq!(effective_gain(0.5, 0.5, 1.0), 0.25);
// Amplification beyond 1.0 is allowed on purpose.
assert_eq!(effective_gain(2.0, 1.0, 1.0), 2.0);
assert_eq!(effective_gain(2.0, 1.0, 3.0), 6.0);
// Anything that is not a usable gain collapses to silence.
assert_eq!(effective_gain(0.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(-1.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::NAN, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::INFINITY, 1.0, 1.0), 0.0);
}
fn stream_node(name: &str, with_ports: bool) -> AudioDevice {
use crate::types::pipewire::Port;
let mut device = AudioDevice::new(1, None, None, Some(name), DeviceType::Output);
if with_ports {
device.add_port(Port {
node_id: 1,
port_id: 1,
name: "output_FL".to_string(),
});
device.add_port(Port {
node_id: 1,
port_id: 2,
name: "output_FR".to_string(),
});
}
device
}
#[test]
fn test_is_own_stream_node() {
assert!(is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
true
)));
assert!(is_own_stream_node(&stream_node("PWSP-daemon", true)));
// Somebody else's playback stream must never be treated as ours.
assert!(!is_own_stream_node(&stream_node("Firefox", true)));
// Nor a node whose ports have not been discovered yet.
assert!(!is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
false
)));
}
}
+217 -10
View File
@@ -1,12 +1,12 @@
use crate::{
types::{
audio_player::{FullState, PlayerState},
config::HotkeyConfig,
audio_player::{FullState, PlayerState, VolumeTarget},
config::{DaemonConfig, HotkeyConfig},
socket::{Request, Response},
},
utils::{
commands::parse_command,
daemon::get_audio_player,
daemon::{get_audio_player, with_daemon_config},
pipewire::{get_all_devices, get_device},
},
};
@@ -46,11 +46,26 @@ pub struct GetVolumeCommand {
pub id: Option<u32>,
}
pub struct GetVolumeMultiplierCommand {}
pub struct SetVolumeCommand {
pub volume: Option<f32>,
pub id: Option<u32>,
}
pub struct SetVolumeMultiplierCommand {
pub volume_multiplier: Option<f32>,
}
pub struct GetMasterVolumeCommand {
pub target: VolumeTarget,
}
pub struct SetMasterVolumeCommand {
pub volume: Option<f32>,
pub target: VolumeTarget,
}
pub struct GetPositionCommand {
pub id: Option<u32>,
}
@@ -79,6 +94,14 @@ pub struct SetCurrentInputCommand {
pub name: Option<String>,
}
pub struct GetCurrentOutputCommand {}
pub struct GetAllOutputsCommand {}
pub struct SetCurrentOutputCommand {
pub name: Option<String>,
}
pub struct SetLoopCommand {
pub enabled: Option<bool>,
pub id: Option<u32>,
@@ -127,6 +150,14 @@ pub struct ClearHotkeyKeyCommand {
pub slot: Option<String>,
}
pub struct GetDaemonConfigCommand {}
pub struct SaveDaemonConfigCommand {}
pub struct UpdateDaemonConfigCommand {
pub new_config: DaemonConfig,
}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
@@ -184,7 +215,7 @@ impl Executable for TogglePauseCommand {
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get(&id) {
if track.sink.is_paused() {
if track.players.primary().is_paused() {
audio_player.resume(Some(id));
Response::new(true, "Audio was resumed")
} else {
@@ -248,7 +279,7 @@ impl Executable for GetStateCommand {
#[async_trait]
impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
@@ -262,10 +293,28 @@ impl Executable for GetVolumeCommand {
}
}
#[async_trait]
impl Executable for GetVolumeMultiplierCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
Response::new(true, audio_player.volume_multiplier.to_string())
}
}
/// Rejects values that would corrupt the mixer. The daemon is the trust boundary here:
/// hotkeys store raw `Request` JSON, so a bad value can arrive without passing the CLI.
fn validate_volume(volume: Option<f32>) -> Option<f32> {
volume.filter(|v| v.is_finite() && *v >= 0.0)
}
#[async_trait]
impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = self.volume {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
@@ -278,6 +327,60 @@ impl Executable for SetVolumeCommand {
}
}
#[async_trait]
impl Executable for GetMasterVolumeCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
Response::new(
true,
audio_player.get_master_volume(self.target).to_string(),
)
}
}
#[async_trait]
impl Executable for SetMasterVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_master_volume(volume, self.target);
let name = match self.target {
VolumeTarget::Monitoring => "Monitoring",
VolumeTarget::Mic => "Microphone",
};
Response::new(true, format!("{} volume was set to {}", name, volume))
} else {
Response::new(false, "Invalid volume value")
}
}
}
#[async_trait]
impl Executable for SetVolumeMultiplierCommand {
async fn execute(&self) -> Response {
if let Some(volume_multiplier) = validate_volume(self.volume_multiplier) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume_multiplier(volume_multiplier);
Response::new(
true,
format!("Audio volume multiplier was set to {}", volume_multiplier),
)
} else {
Response::new(false, "Invalid volume multiplier value")
}
}
}
#[async_trait]
impl Executable for GetPositionCommand {
async fn execute(&self) -> Response {
@@ -383,8 +486,8 @@ impl Executable for GetCurrentInputCommand {
#[async_trait]
impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
Ok(devices) => devices,
let input_devices = match get_all_devices().await {
Ok(devices) => devices.inputs,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut input_devices_strings = vec![];
@@ -420,6 +523,66 @@ impl Executable for SetCurrentInputCommand {
}
}
#[async_trait]
impl Executable for GetCurrentOutputCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match &audio_player.output_device_name {
Some(name) => Response::new(true, name),
// No pinned device means playback follows whatever the system default is.
None => Response::new(true, ""),
}
}
}
#[async_trait]
impl Executable for GetAllOutputsCommand {
async fn execute(&self) -> Response {
let sinks = match get_all_devices().await {
Ok(devices) => devices.sinks,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let response_message = sinks
.into_iter()
.map(|device| format!("{} - {}", device.name, device.nick))
.collect::<Vec<_>>()
.join("; ");
Response::new(true, response_message)
}
}
#[async_trait]
impl Executable for SetCurrentOutputCommand {
async fn execute(&self) -> Response {
let Some(name) = &self.name else {
return Response::new(false, "Invalid device name");
};
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
// An empty name is how a client asks to stop pinning a device and follow the
// system default again.
if name.is_empty() {
audio_player.clear_current_output_device();
return Response::new(true, "Output device follows the system default");
}
match audio_player.set_current_output_device(name).await {
Ok(_) => Response::new(true, "Output device was set"),
Err(err) => Response::new(false, err.to_string()),
}
}
}
#[async_trait]
impl Executable for SetLoopCommand {
async fn execute(&self) -> Response {
@@ -472,10 +635,11 @@ impl Executable for GetDaemonVersionCommand {
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let input_devices = devices.inputs;
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
@@ -504,12 +668,22 @@ impl Executable for GetFullStateCommand {
}
}
let all_outputs: HashMap<String, String> = devices
.sinks
.into_iter()
.map(|device| (device.name, device.nick))
.collect();
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
volume: audio_player.volume,
monitoring_volume: audio_player.monitoring_volume,
mic_volume: audio_player.mic_volume,
volume_multiplier: audio_player.volume_multiplier,
current_input: current_input_nick,
all_inputs,
current_output: audio_player.output_device_name.clone().unwrap_or_default(),
all_outputs,
};
match serde_json::to_string(&full_state) {
@@ -723,3 +897,36 @@ impl Executable for ClearHotkeyKeyCommand {
}
}
}
#[async_trait]
impl Executable for GetDaemonConfigCommand {
async fn execute(&self) -> Response {
let serialized = with_daemon_config(|c| serde_json::to_string(&c));
match serialized {
Ok(s) => Response::new(true, s),
Err(err) => Response::new(false, format!("Failed to serialize daemon config: {}", err)),
}
}
}
#[async_trait]
impl Executable for SaveDaemonConfigCommand {
async fn execute(&self) -> Response {
match with_daemon_config(|c| c.save_to_file()) {
Ok(_) => Response::new(true, "Daemon config saved successfully"),
Err(err) => Response::new(false, format!("Failed to save daemon config: {}", err)),
}
}
}
#[async_trait]
impl Executable for UpdateDaemonConfigCommand {
async fn execute(&self) -> Response {
with_daemon_config(|c| {
c.clone_from(&self.new_config);
});
Response::new(true, "Daemon config updated successfully")
}
}
+10 -2
View File
@@ -16,7 +16,10 @@ use std::{
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
pub default_output_name: Option<String>,
pub default_monitoring_volume: Option<f32>,
pub default_mic_volume: Option<f32>,
pub default_volume_multiplier: Option<f32>,
}
impl DaemonConfig {
@@ -73,6 +76,7 @@ pub struct GuiConfig {
pub left_panel_width: f32,
pub save_volume: bool,
pub save_volume_multiplier: bool,
pub save_input: bool,
pub save_scale_factor: bool,
pub pause_on_exit: bool,
@@ -81,6 +85,8 @@ pub struct GuiConfig {
pub dirs_settings: HashMap<PathBuf, DirSettings>,
pub preferred_theme: PreferredTheme,
/// UI language code, or `None` to follow the system locale.
pub forced_lang: Option<String>,
}
impl SortOrder {
@@ -117,14 +123,16 @@ impl Default for GuiConfig {
left_panel_width: 280.0,
save_volume: false,
save_volume_multiplier: false,
save_input: false,
save_scale_factor: false,
pause_on_exit: false,
dirs: vec![ensure_pwsp_audio_dir()],
dirs: vec![ensure_pwsp_audio_dir().unwrap()],
preferred_theme: PreferredTheme::System,
dirs_settings: HashMap::new(),
forced_lang: None,
}
}
}
+112 -14
View File
@@ -8,19 +8,65 @@ use egui::Id;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
time::Instant,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub type ScanResult = (PathBuf, Vec<PathBuf>, HashMap<PathBuf, Vec<PathBuf>>);
/// How long the daemon's value is ignored after a slider commits, so the knob does not
/// snap back before the daemon has caught up.
const SETTLE_TIME: Duration = Duration::from_millis(300);
/// A slider whose local value wins over the daemon's while the user is interacting.
///
/// The daemon is polled at 60 Hz, so without this the value would fight the user mid-drag
/// and jump back right after release.
#[derive(Default, Debug)]
pub struct SliderLatch {
pub value: f32,
pub dragged: bool,
pub ignore_update_until: Option<Instant>,
}
impl SliderLatch {
/// Whether the daemon's value may overwrite the local one this frame.
fn should_sync(&self) -> bool {
!self.dragged
&& self
.ignore_update_until
.is_none_or(|until| Instant::now() > until)
}
/// Adopts the daemon's value unless the user is currently driving the slider.
pub fn sync(&mut self, value: f32) {
if self.should_sync() {
self.value = value;
}
}
/// Called once the pending change has been sent to the daemon.
fn commit(&mut self) {
self.dragged = false;
self.ignore_update_until = Some(Instant::now() + SETTLE_TIME);
}
/// Returns the value to send, if the user finished a change that has not been sent yet.
pub fn take_pending(&mut self) -> Option<f32> {
if self.dragged {
let value = self.value;
self.commit();
Some(value)
} else {
None
}
}
}
#[derive(Default, Debug)]
pub struct TrackUiState {
pub position_slider_value: f32,
pub volume_slider_value: f32,
pub position_dragged: bool,
pub volume_dragged: bool,
pub ignore_position_update_until: Option<Instant>,
pub ignore_volume_update_until: Option<Instant>,
pub position: SliderLatch,
pub volume: SliderLatch,
}
#[derive(Default, Debug)]
@@ -30,15 +76,14 @@ pub struct AppState {
pub track_ui_states: HashMap<u32, TrackUiState>,
pub show_settings: bool,
pub volume_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32,
pub monitoring_volume: SliderLatch,
pub mic_volume: SliderLatch,
pub volume_multiplier: SliderLatch,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>,
pub current_dir: Option<PathBuf>,
pub dirs: Vec<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>,
@@ -47,6 +92,11 @@ pub struct AppState {
pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub scanning_dirs: Arc<Mutex<HashSet<PathBuf>>>,
pub scanned_this_session: HashSet<PathBuf>,
pub finished_scans: Arc<Mutex<Vec<ScanResult>>>,
pub recursive_files_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub show_hotkeys: bool,
pub hotkey_capture_active: bool,
@@ -64,13 +114,61 @@ pub struct AudioPlayerState {
pub tracks: Vec<TrackInfo>,
pub volume: f32, // Master volume
/// What the user hears locally.
pub monitoring_volume: f32,
/// What is sent to the virtual microphone.
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
/// Empty means "follow the system default sink".
pub current_output: String,
pub all_outputs: HashMap<String, String>,
pub all_outputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slider_latch_syncs_when_idle() {
let mut latch = SliderLatch::default();
latch.sync(0.7);
assert_eq!(latch.value, 0.7);
// While the user drags, the daemon must not move the knob out from under them.
latch.dragged = true;
latch.sync(0.2);
assert_eq!(latch.value, 0.7);
}
#[test]
fn test_slider_latch_take_pending() {
let mut latch = SliderLatch::default();
// Nothing to send until the user actually changes something.
assert_eq!(latch.take_pending(), None);
latch.value = 1.5;
latch.dragged = true;
assert_eq!(latch.take_pending(), Some(1.5));
// The change is sent exactly once.
assert_eq!(latch.take_pending(), None);
// The daemon is then ignored briefly, so a stale value already in flight cannot
// snap the slider back.
assert!(!latch.should_sync());
latch.sync(0.1);
assert_eq!(latch.value, 1.5);
}
}
+37 -3
View File
@@ -13,6 +13,15 @@ pub struct Port {
pub enum DeviceType {
Input,
Output,
Sink,
}
/// A link between two nodes in the PipeWire graph.
#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)]
pub struct LinkInfo {
pub id: u32,
pub output_node: u32,
pub input_node: u32,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
@@ -55,12 +64,14 @@ impl AudioDevice {
}
pub fn add_port(&mut self, port: Port) {
// `playback_*` are the input ports of an `Audio/Sink` node. `monitor_*` are its
// output ports and are deliberately left unmapped — we never record from a sink.
match port.name.as_str() {
"input_FL" => self.input_fl = Some(port),
"input_FR" => self.input_fr = Some(port),
"input_FL" | "playback_FL" => self.input_fl = Some(port),
"input_FR" | "playback_FR" => self.input_fr = Some(port),
"output_FL" | "capture_FL" => self.output_fl = Some(port),
"output_FR" | "capture_FR" => self.output_fr = Some(port),
"input_MONO" => {
"input_MONO" | "playback_MONO" => {
self.input_fl = Some(port.clone());
self.input_fr = Some(port);
}
@@ -100,6 +111,29 @@ mod tests {
assert_eq!(device_no_desc.nick, "Name");
}
#[test]
fn test_audio_device_sink_ports() {
let mut sink = AudioDevice::new(1, None, None, Some("speakers"), DeviceType::Sink);
let port = |id: u32, name: &str| Port {
node_id: 1,
port_id: id,
name: name.to_string(),
};
// playback_* carries audio into the sink, so those are its inputs.
sink.add_port(port(10, "playback_FL"));
sink.add_port(port(11, "playback_FR"));
assert_eq!(sink.input_fl, Some(port(10, "playback_FL")));
assert_eq!(sink.input_fr, Some(port(11, "playback_FR")));
// monitor_* is what the sink plays out; linking a stream there would be a loop.
sink.add_port(port(12, "monitor_FL"));
sink.add_port(port(13, "monitor_FR"));
assert_eq!(sink.output_fl, None);
assert_eq!(sink.output_fr, None);
}
#[test]
fn test_audio_device_add_port() {
let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input);
+96
View File
@@ -1,3 +1,4 @@
use crate::types::config::DaemonConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -94,6 +95,10 @@ impl Request {
Request::new("get_volume", args)
}
pub fn get_volume_multiplier() -> Self {
Request::new("get_volume_multiplier", vec![])
}
pub fn get_position(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
@@ -138,6 +143,32 @@ impl Request {
Request::new("set_volume".to_string(), args)
}
pub fn get_monitoring_volume() -> Self {
Request::new("get_monitoring_volume", vec![])
}
pub fn set_monitoring_volume(volume: f32) -> Self {
Request::new(
"set_monitoring_volume",
vec![("volume", &volume.to_string())],
)
}
pub fn get_mic_volume() -> Self {
Request::new("get_mic_volume", vec![])
}
pub fn set_mic_volume(volume: f32) -> Self {
Request::new("set_mic_volume", vec![("volume", &volume.to_string())])
}
pub fn set_volume_multiplier(volume: f32) -> Self {
Request::new(
"set_volume_multiplier",
vec![("volume_multiplier", &volume.to_string())],
)
}
pub fn seek(position: f32, id: Option<u32>) -> Self {
let mut args = vec![("position".to_string(), position.to_string())];
if let Some(id) = id {
@@ -150,6 +181,18 @@ impl Request {
Request::new("set_input", vec![("input_name", name)])
}
pub fn get_output() -> Self {
Request::new("get_output", vec![])
}
pub fn get_outputs() -> Self {
Request::new("get_outputs", vec![])
}
pub fn set_output(name: &str) -> Self {
Request::new("set_output", vec![("output_name", name)])
}
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
let mut args = vec![("enabled".to_string(), enabled.to_string())];
if let Some(id) = id {
@@ -172,6 +215,24 @@ impl Request {
Request::new("get_daemon_version", vec![])
}
pub fn get_daemon_config() -> Self {
Request::new("get_daemon_config", vec![])
}
pub fn save_daemon_config() -> Self {
Request::new("save_daemon_config", vec![])
}
pub fn update_daemon_config(new_config: &DaemonConfig) -> Self {
Request::new(
"update_daemon_config",
vec![(
"new_config",
&serde_json::to_string(&new_config).unwrap_or_default(),
)],
)
}
pub fn get_full_state() -> Self {
Request::new("get_full_state", vec![])
}
@@ -250,6 +311,41 @@ mod tests {
assert_eq!(res.message, "success-msg");
}
#[test]
fn test_volume_path_request_constructors() {
let monitoring = Request::set_monitoring_volume(1.5);
assert_eq!(monitoring.name, "set_monitoring_volume");
assert_eq!(
monitoring.args.get("volume").map(|s| s.as_str()),
Some("1.5")
);
let mic = Request::set_mic_volume(2.0);
assert_eq!(mic.name, "set_mic_volume");
assert_eq!(mic.args.get("volume").map(|s| s.as_str()), Some("2"));
assert_eq!(
Request::get_monitoring_volume().name,
"get_monitoring_volume"
);
assert!(Request::get_monitoring_volume().args.is_empty());
assert_eq!(Request::get_mic_volume().name, "get_mic_volume");
}
#[test]
fn test_output_request_constructors() {
let set_output = Request::set_output("some-sink");
assert_eq!(set_output.name, "set_output");
assert_eq!(
set_output.args.get("output_name").map(|s| s.as_str()),
Some("some-sink")
);
assert_eq!(Request::get_output().name, "get_output");
assert_eq!(Request::get_outputs().name, "get_outputs");
assert!(Request::get_outputs().args.is_empty());
}
#[test]
fn test_request_constructors() {
// test ping
+107 -1
View File
@@ -1,7 +1,18 @@
use crate::types::{commands::*, socket::Request};
use crate::types::{
audio_player::VolumeTarget, commands::*, config::DaemonConfig, socket::Request,
};
use std::path::PathBuf;
/// Which of the two output paths a `*_monitoring_volume` / `*_mic_volume` command means.
fn volume_target(command_name: &str) -> Option<VolumeTarget> {
match command_name {
"get_monitoring_volume" | "set_monitoring_volume" => Some(VolumeTarget::Monitoring),
"get_mic_volume" | "set_mic_volume" => Some(VolumeTarget::Mic),
_ => None,
}
}
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
@@ -15,6 +26,7 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
"is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
"get_volume_multiplier" => Some(Box::new(GetVolumeMultiplierCommand {})),
"set_volume" => {
let volume = request
.args
@@ -24,6 +36,30 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.ok();
Some(Box::new(SetVolumeCommand { volume, id }))
}
"get_monitoring_volume" | "get_mic_volume" => Some(Box::new(GetMasterVolumeCommand {
target: volume_target(&request.name)?,
})),
"set_monitoring_volume" | "set_mic_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetMasterVolumeCommand {
volume,
target: volume_target(&request.name)?,
}))
}
"set_volume_multiplier" => {
let volume_multiplier = request
.args
.get("volume_multiplier")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeMultiplierCommand { volume_multiplier }))
}
"get_position" => Some(Box::new(GetPositionCommand { id })),
"seek" => {
let position = request
@@ -60,6 +96,12 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
"get_output" => Some(Box::new(GetCurrentOutputCommand {})),
"get_outputs" => Some(Box::new(GetAllOutputsCommand {})),
"set_output" => {
let name = Some(request.args.get("output_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentOutputCommand { name }))
}
"set_loop" => {
let enabled = request
.args
@@ -119,6 +161,17 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
key_chord,
}))
}
"get_daemon_config" => Some(Box::new(GetDaemonConfigCommand {})),
"save_daemon_config" => Some(Box::new(SaveDaemonConfigCommand {})),
"update_daemon_config" => {
let new_config = request
.args
.get("new_config")
.and_then(|nc| serde_json::from_str::<DaemonConfig>(nc).ok())
.unwrap_or_default();
Some(Box::new(UpdateDaemonConfigCommand { new_config }))
}
_ => None,
}
}
@@ -206,4 +259,57 @@ mod tests {
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_volume_target_mapping() {
assert_eq!(
volume_target("set_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(
volume_target("get_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(volume_target("set_mic_volume"), Some(VolumeTarget::Mic));
assert_eq!(volume_target("get_mic_volume"), Some(VolumeTarget::Mic));
// The two paths share a dispatch arm, so a name that is neither must not
// silently fall through to one of them.
assert_eq!(volume_target("set_volume"), None);
assert_eq!(volume_target("mic"), None);
}
#[test]
fn test_parse_new_commands() {
let with_volume = |name: &str| {
let mut args = HashMap::new();
args.insert("volume".to_string(), "2.0".to_string());
Request {
name: name.to_string(),
args,
}
};
assert!(parse_command(&with_volume("set_monitoring_volume")).is_some());
assert!(parse_command(&with_volume("set_mic_volume")).is_some());
for name in [
"get_monitoring_volume",
"get_mic_volume",
"get_output",
"get_outputs",
] {
let request = Request::new(name, vec![]);
assert!(
parse_command(&request).is_some(),
"{} did not dispatch",
name
);
}
let set_output = Request::set_output("some-sink");
assert!(parse_command(&set_output).is_some());
assert!(parse_command(&Request::new("set_speaker_volume", vec![])).is_none());
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
use anyhow::Result;
use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
let config_path = dirs::config_dir().context("Failed to obtain config dir")?;
Ok(config_path.join("pwsp"))
}
+23 -7
View File
@@ -7,36 +7,52 @@ use crate::types::{
use anyhow::Result;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::{env, error::Error, fs};
use std::{
env,
error::Error,
fs,
sync::{Arc, Mutex, OnceLock},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::{Mutex, OnceCell},
sync::{Mutex as AsyncMutex, OnceCell},
time::{Duration, sleep},
};
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
static AUDIO_PLAYER: OnceCell<AsyncMutex<AudioPlayer>> = OnceCell::const_new();
static DAEMON_CONFIG: OnceLock<Arc<Mutex<DaemonConfig>>> = OnceLock::new();
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
pub async fn get_audio_player() -> Result<&'static AsyncMutex<AudioPlayer>, String> {
AUDIO_PLAYER
.get_or_try_init(|| async {
println!("Initializing audio player");
match AudioPlayer::new().await {
Ok(player) => Ok(Mutex::new(player)),
Ok(player) => Ok(AsyncMutex::new(player)),
Err(err) => Err(err.to_string()),
}
})
.await
}
pub fn get_daemon_config() -> DaemonConfig {
DaemonConfig::load_from_file().unwrap_or_else(|_| {
pub fn get_daemon_config() -> &'static Arc<Mutex<DaemonConfig>> {
DAEMON_CONFIG.get_or_init(|| {
Arc::new(Mutex::new(DaemonConfig::load_from_file().unwrap_or_else(
|_| {
let config = DaemonConfig::default();
config.save_to_file().ok();
config
},
)))
})
}
pub fn with_daemon_config<R>(f: impl FnOnce(&mut DaemonConfig) -> R) -> R {
let config = get_daemon_config().clone();
let mut guard = config.lock().unwrap();
f(&mut guard)
}
fn get_current_uid() -> u32 {
rustix::process::geteuid().as_raw()
}
+44 -13
View File
@@ -1,7 +1,7 @@
use crate::{
types::{
audio_player::FullState,
config::{GuiConfig, HotkeyConfig},
config::{DaemonConfig, GuiConfig, HotkeyConfig},
gui::AudioPlayerState,
socket::{Request, Response},
},
@@ -9,6 +9,8 @@ use crate::{
};
use anyhow::{Result, anyhow};
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
@@ -23,6 +25,19 @@ pub fn get_gui_config() -> GuiConfig {
})
}
pub fn update_daemon_config(new_config: &DaemonConfig) -> Result<()> {
make_request_sync(Request::update_daemon_config(new_config))?;
make_request_async(Request::save_daemon_config());
Ok(())
}
pub fn get_daemon_config() -> Result<DaemonConfig> {
let response = make_request_sync(Request::get_daemon_config())?;
let config: DaemonConfig = serde_json::from_str(&response.message)?;
Ok(config)
}
pub fn make_request_sync(request: Request) -> Result<Response> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
@@ -37,15 +52,19 @@ pub fn make_request_async(request: Request) {
});
}
pub fn ensure_pwsp_audio_dir() -> PathBuf {
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
pub fn ensure_pwsp_audio_dir() -> Result<PathBuf> {
let audio_dir = dirs::audio_dir().unwrap_or(
dirs::home_dir()
.map(|p| p.join("Music"))
.ok_or_else(|| anyhow!("Failed to get home directory. Is your system ok?"))?,
);
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
std::fs::create_dir_all(&pwsp_audio_dir).ok();
fs::create_dir_all(&pwsp_audio_dir)?;
}
pwsp_audio_dir
Ok(pwsp_audio_dir)
}
pub fn format_time_pair(position: f32, duration: f32) -> String {
@@ -59,6 +78,16 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
format!("{}/{}", format_time(position), format_time(duration))
}
/// Turns a name→nickname map into the stable order the combo boxes render in.
fn sorted_devices(devices: &HashMap<String, String>) -> Vec<(String, String)> {
let mut sorted: Vec<(String, String)> = devices
.iter()
.map(|(name, nick)| (name.clone(), nick.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
sorted
}
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
@@ -97,23 +126,25 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
None => full_state.state,
};
guard.tracks = full_state.tracks;
guard.volume = full_state.volume;
guard.monitoring_volume = full_state.monitoring_volume;
guard.mic_volume = full_state.mic_volume;
guard.volume_multiplier = full_state.volume_multiplier;
guard.current_input = full_state
.current_input
.split(" - ")
.next()
.unwrap_or_default()
.to_string();
guard.current_output = full_state.current_output;
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
guard.all_inputs_sorted = sorted_devices(&guard.all_inputs);
}
if guard.all_outputs != full_state.all_outputs {
guard.all_outputs = full_state.all_outputs;
guard.all_outputs_sorted = sorted_devices(&guard.all_outputs);
}
guard.is_daemon_running = true;
+209 -76
View File
@@ -1,4 +1,4 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port};
use crate::types::pipewire::{AudioDevice, DeviceType, LinkInfo, Port};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
@@ -7,9 +7,31 @@ use pipewire::{
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
use tokio::sync::oneshot;
/// Every audio node PWSP knows about, split by role.
pub struct AllDevices {
/// `Audio/Source*` nodes — real and virtual microphones.
pub inputs: Vec<AudioDevice>,
/// `Stream/Output/Audio` nodes — application playback streams, including our own.
pub outputs: Vec<AudioDevice>,
/// `Audio/Sink` nodes — speakers and headphones.
pub sinks: Vec<AudioDevice>,
}
impl AllDevices {
pub fn iter(&self) -> impl Iterator<Item = &AudioDevice> {
self.inputs
.iter()
.chain(self.outputs.iter())
.chain(self.sinks.iter())
}
}
pub enum PwCommand {
GetDevices {
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
resp: oneshot::Sender<AllDevices>,
},
GetLinks {
resp: oneshot::Sender<Vec<LinkInfo>>,
},
CreateVirtualMic {
resp: oneshot::Sender<Result<u32, String>>,
@@ -21,14 +43,21 @@ pub enum PwCommand {
input_fr: Port,
resp: oneshot::Sender<Result<(u32, u32), String>>,
},
/// Drops a proxy we created ourselves, which destroys the underlying object.
DestroyObject {
id: u32,
},
/// Destroys a global object owned by somebody else, such as an auto-created link.
DestroyGlobal {
id: u32,
},
}
struct AppState {
input_devices: HashMap<u32, AudioDevice>,
output_devices: HashMap<u32, AudioDevice>,
sink_devices: HashMap<u32, AudioDevice>,
links: HashMap<u32, LinkInfo>,
ports: HashMap<u32, Port>,
proxies: HashMap<u32, Box<dyn std::any::Any>>,
proxy_id_counter: u32,
@@ -53,19 +82,23 @@ pub fn get_manager() -> &'static PipewireManager {
let main_loop = Box::leak(Box::new(main_loop));
let context = Box::leak(Box::new(context));
// Leak to fix lifetime issues since this thread lives forever
let core = Box::leak(Box::new(
// Leak to fix lifetime issues since this thread lives forever. Shared rather
// than mutable so both `core` and the `registry` borrowed from it can be
// captured by the command closure below.
let core: &'static _ = Box::leak(Box::new(
context
.connect(None)
.expect("Failed to connect to pipewire"),
));
let registry = Box::leak(Box::new(
let registry: &'static _ = Box::leak(Box::new(
core.get_registry().expect("Failed to get registry"),
));
let state = Rc::new(RefCell::new(AppState {
input_devices: HashMap::new(),
output_devices: HashMap::new(),
sink_devices: HashMap::new(),
links: HashMap::new(),
ports: HashMap::new(),
proxies: HashMap::new(),
proxy_id_counter: 10000,
@@ -78,31 +111,44 @@ pub fn get_manager() -> &'static PipewireManager {
let _listener = registry
.add_listener_local()
.global(move |global| {
let (device, port) = parse_global_object(global);
let mut s = state_for_registry_add.borrow_mut();
if let Some(device) = device {
match device.device_type {
match parse_global_object(global) {
ParsedGlobal::Device(device) => match device.device_type {
DeviceType::Input => {
s.input_devices.insert(device.id, device);
}
DeviceType::Output => {
s.output_devices.insert(device.id, device);
}
DeviceType::Sink => {
s.sink_devices.insert(device.id, device);
}
} else if let Some(port) = port {
},
ParsedGlobal::Port(port) => {
let node_id = port.node_id;
s.ports.insert(port.port_id, port.clone());
if let Some(d) = s.input_devices.get_mut(&node_id) {
d.add_port(port.clone());
d.add_port(port);
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
d.add_port(port);
} else if let Some(d) = s.sink_devices.get_mut(&node_id) {
d.add_port(port);
}
}
ParsedGlobal::Link(link) => {
s.links.insert(link.id, link);
}
ParsedGlobal::Unknown => {}
}
})
.global_remove(move |id| {
let mut s = state_for_registry_remove.borrow_mut();
s.input_devices.remove(&id);
s.output_devices.remove(&id);
s.sink_devices.remove(&id);
s.links.remove(&id);
s.links
.retain(|_, link| link.output_node != id && link.input_node != id);
s.ports.retain(|_, port| port.node_id != id);
s.ports.remove(&id);
})
@@ -133,9 +179,21 @@ pub fn get_manager() -> &'static PipewireManager {
s.input_devices.values().cloned().collect();
let mut outputs: Vec<AudioDevice> =
s.output_devices.values().cloned().collect();
let mut sinks: Vec<AudioDevice> =
s.sink_devices.values().cloned().collect();
inputs.sort_by_key(|a| a.id);
outputs.sort_by_key(|a| a.id);
let _ = resp.send((inputs, outputs));
sinks.sort_by_key(|a| a.id);
let _ = resp.send(AllDevices {
inputs,
outputs,
sinks,
});
}
PwCommand::GetLinks { resp } => {
let mut links: Vec<LinkInfo> = s.links.values().copied().collect();
links.sort_by_key(|l| l.id);
let _ = resp.send(links);
}
PwCommand::CreateVirtualMic { resp } => {
let props = properties!(
@@ -207,6 +265,10 @@ pub fn get_manager() -> &'static PipewireManager {
PwCommand::DestroyObject { id } => {
s.proxies.remove(&id);
}
PwCommand::DestroyGlobal { id } => {
s.links.remove(&id);
registry.destroy_global(id);
}
}
});
@@ -227,12 +289,17 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
enum ParsedGlobal {
Device(AudioDevice),
Port(Port),
Link(LinkInfo),
Unknown,
}
fn parse_global_object(global_object: &GlobalObject<&DictRef>) -> ParsedGlobal {
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
None => return ParsedGlobal::Unknown,
};
if let Some(media_class) = props.get("media.class") {
@@ -241,26 +308,40 @@ fn parse_global_object(
let node_name = props.get("node.name");
let node_description = props.get("node.description");
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Input,
);
return (Some(input_device), None);
// `Audio/Source/Virtual` (our own virtual mic) also matches "Audio/Source",
// which is intended — it is a microphone as far as the rest of PWSP cares.
let device_type = if media_class.starts_with("Audio/Source") {
DeviceType::Input
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice::new(
DeviceType::Output
} else if media_class.starts_with("Audio/Sink") {
DeviceType::Sink
} else {
return ParsedGlobal::Unknown;
};
return ParsedGlobal::Device(AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Output,
);
return (Some(output_device), None);
device_type,
));
}
return (None, None);
if let (Some(output_node), Some(input_node)) = (
props
.get("link.output.node")
.and_then(|id| id.parse::<u32>().ok()),
props
.get("link.input.node")
.and_then(|id| id.parse::<u32>().ok()),
) {
return ParsedGlobal::Link(LinkInfo {
id: global_object.id,
output_node,
input_node,
});
}
if props.get("port.direction").is_some()
@@ -270,18 +351,17 @@ fn parse_global_object(
props.get("port.name"),
)
{
let port = Port {
return ParsedGlobal::Port(Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
});
}
(None, None)
ParsedGlobal::Unknown
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
pub async fn get_all_devices() -> Result<AllDevices> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
@@ -294,19 +374,52 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
Ok(res)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (input_devices, output_devices) = get_all_devices().await?;
pub async fn get_all_links() -> Result<Vec<LinkInfo>> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetLinks { resp: tx })
.map_err(|_| anyhow!("Failed to send GetLinks to manager"))?;
rx.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))
}
input_devices
.into_iter()
.chain(output_devices)
.find(|device| {
fn matches_device_name(device: &AudioDevice, device_name: &str) -> bool {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| anyhow!("Device not found"))
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let devices = get_all_devices().await?;
devices
.iter()
.find(|device| matches_device_name(device, device_name))
.cloned()
.ok_or_else(|| anyhow!("Device not found: {}", device_name))
}
/// Re-reads a node by its PipeWire id, so callers always link against fresh ports.
pub async fn get_device_by_id(id: u32) -> Result<AudioDevice> {
get_all_devices()
.await?
.iter()
.find(|device| device.id == id)
.cloned()
.ok_or_else(|| anyhow!("Node {} is gone", id))
}
/// Looks up an `Audio/Sink` node by name.
pub async fn get_sink(name: &str) -> Result<AudioDevice> {
get_all_devices()
.await?
.sinks
.into_iter()
.find(|d| matches_device_name(d, name))
.ok_or_else(|| anyhow!("Output device not found: {}", name))
}
pub struct PwTerminator {
@@ -338,43 +451,63 @@ pub async fn create_virtual_mic() -> Result<PwTerminator> {
Ok(PwTerminator { ids: vec![id] })
}
pub async fn link_player_to_virtual_mic() -> Result<PwTerminator> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
));
/// Makes `source_node` feed `target` and nothing else.
///
/// Idempotent: safe to call on every device-check tick. Returns `Some` only when a new link
/// was created, so an already-correct route is left in place and the caller never tears
/// down a link it does not own.
///
/// The link is created *before* stale ones are pruned. That order matters: the node is
/// never left unlinked, which is the state that would invite the session manager to
/// re-attach it somewhere else.
pub async fn ensure_route(
source_node: &AudioDevice,
target: &AudioDevice,
) -> Result<Option<PwTerminator>> {
let existing = get_all_links().await?;
let already_routed = existing
.iter()
.any(|link| link.output_node == source_node.id && link.input_node == target.id);
let terminator = if already_routed {
None
} else {
let output_fl = source_node
.output_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FL port", source_node.name))?;
let output_fr = source_node
.output_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FR port", source_node.name))?;
let input_fl = target
.input_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FL port", target.name))?;
let input_fr = target
.input_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FR port", target.name))?;
Some(create_link(output_fl, output_fr, input_fl, input_fr).await?)
};
prune_links_from(source_node.id, target.id).await?;
Ok(terminator)
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find pwsp-virtual-mic device, skipping device linking"
));
/// Destroys every link leaving `source_node` that does not end at `keep_target`.
async fn prune_links_from(source_node: u32, keep_target: u32) -> Result<()> {
let manager = get_manager();
for link in get_all_links().await? {
if link.output_node == source_node && link.input_node != keep_target {
let _ = manager
.sender
.send(PwCommand::DestroyGlobal { id: link.id });
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr).await
}
Ok(())
}
pub async fn create_link(
+6 -12
View File
@@ -27,17 +27,17 @@ with open(cargo_toml_path, "r", encoding="utf-8") as f:
# We want to match version in [workspace.package]
# First, let's find the [workspace.package] section
workspace_package_match = re.search(
r"\[workspace\.package\](.*?)(?=\n\[|$)", cargo_toml_content, re.DOTALL
)
workspace_package_match = re.search(r"\[workspace\.package\](.*?)(?=\n\[|$)", cargo_toml_content, re.DOTALL)
if not workspace_package_match:
fatal("Could not find [workspace.package] section in Cargo.toml.")
if isinstance(workspace_package_match, re.Match):
workspace_package_sec = workspace_package_match.group(1)
version_match = re.search(r'version\s*=\s*"([^"]+)"', workspace_package_sec)
if not version_match:
fatal("Could not find version in [workspace.package] in Cargo.toml.")
if isinstance(version_match, re.Match):
current_version = version_match.group(1)
print(f"Current version detected: {current_version}")
@@ -62,9 +62,7 @@ print("Updating Cargo.toml...")
def replace_version_in_workspace(match):
section_content = match.group(1)
updated_section_content = re.sub(
r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_version}\g<2>", section_content
)
updated_section_content = re.sub(r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_version}\g<2>", section_content)
return f"[workspace.package]{updated_section_content}"
@@ -127,9 +125,7 @@ def update_srcinfo(directory, pkgbuild_path, srcinfo_path):
f.write(result.stdout)
return
except Exception as e:
print(
f"Warning: makepkg failed in {directory}: {e}. Falling back to text replacement."
)
print(f"Warning: makepkg failed in {directory}: {e}. Falling back to text replacement.")
# Text replacement fallback
with open(srcinfo_path, "r", encoding="utf-8") as f:
@@ -142,9 +138,7 @@ def update_srcinfo(directory, pkgbuild_path, srcinfo_path):
update_srcinfo("packages/aur/bin", pkgbuild_bin_path, "packages/aur/bin/.SRCINFO")
update_srcinfo(
"packages/aur/standart", pkgbuild_std_path, "packages/aur/standart/.SRCINFO"
)
update_srcinfo("packages/aur/standart", pkgbuild_std_path, "packages/aur/standart/.SRCINFO")
# 4. Update packages/flatpak/ru.arabianq.pwsp.metainfo.xml
flatpak_xml_path = "packages/flatpak/ru.arabianq.pwsp.metainfo.xml"