Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,29 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6

- name: Cache APT packages
- name: Install Linux build dependencies
if: runner.os == 'Linux'
# Tauri 2 on Linux still uses GTK 3 + WebKitGTK; cpal needs ALSA
# development headers; the rest mirrors what `cargo check` will
# link against transitively from the Tauri runtime.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: >-
libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev
libayatana-appindicator3-dev librsvg2-dev libasound2-dev
#
# Installed directly rather than via cache-apt-pkgs-action on
# purpose: the runner image's pre-baked apt index pins package
# versions (e.g. gstreamer1.0-plugins-good, a libwebkit2gtk dep)
# that the Ubuntu mirror rotates out, so a cached install with no
# `apt-get update` 404s. The action also keys its cache purely on
# the package list, so a failed run saves an empty cache that
# poisons every later run under the same immutable key. `apt-get
# update` first refreshes the index to whatever the mirror
# currently serves; skipping the apt cache removes the poison
# trap. The expensive cache — compiled Rust artifacts — stays on
# Swatinem/rust-cache below.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev libasound2-dev \
libssl-dev pkg-config
version: v1

- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable
with:
Expand Down
19 changes: 12 additions & 7 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,19 @@ jobs:
# proc macros (#[tauri::command], sqlx::query!, etc.) and external
# crate symbols. Without it, analysis quality drops below the 50 %
# call-target threshold and GitHub flags the run as low-quality.
- name: Cache APT packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: >-
libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev
libayatana-appindicator3-dev librsvg2-dev libasound2-dev
# Installed directly rather than via cache-apt-pkgs-action: the
# runner image's pre-baked apt index pins package versions the
# Ubuntu mirror rotates out, so a cached install with no `apt-get
# update` 404s, and the action's package-list-keyed cache saves an
# empty entry on failure that poisons later runs. Same rationale as
# ci.yml.
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev libasound2-dev \
libssl-dev pkg-config
version: v1

- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable
with:
Expand Down
26 changes: 26 additions & 0 deletions docs/features/playback.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,32 @@ The chosen device's name is persisted in `profile_setting['audio.output_device']

On Linux, enumeration uses ALSA's hint database (`snd_device_name_hint("pcm")`) instead of cpal's `output_devices()` to avoid a 1-2 s freeze + `pcm_dmix` / `pcm_route` stderr spam from probing every PCM card.

## Output-stream lifecycle & recovery

Three paths replace the output stream, and they must all end in the same place: `wasapi_exclusive_active` updated and a `player:audio-mode-changed` event emitted, because that event is the only thing that keeps Settings' Exclusive-mode toggle honest ([`ExclusiveModeCard`](../../src/components/views/settings/ExclusiveModeCard.tsx) re-reads on it).

| Path | Trigger | Order |
| --- | --- | --- |
| `set_output_device` | user picks another endpoint | spawn first, then release — the two streams target different devices, so a failed spawn can roll back to the working one |
| `set_wasapi_exclusive` | user toggles the mode | **release first when the old stream is exclusive**, then spawn |
| `force_rebuild_output` | automatic recovery after a device error | **release first when the old stream is exclusive**, then spawn |

The release-first rule is the #322 / #405 lesson: a WASAPI exclusive client owns its endpoint outright, so no other client — shared *or* exclusive — can open it until that client is released. Re-opening the **same** endpoint while an exclusive stream still holds it always fails, and when it failed inside `set_wasapi_exclusive` the command returned `Err` before persisting anything, leaving the toggle latched on the mode the user was trying to leave (#405).

Device loss reaches the recovery path from two independent places, since the two backends have separate failure surfaces:

- **cpal shared** — the stream's `err_fn` callback fires on an arbitrary thread.
- **WASAPI exclusive** — [`wasapi_exclusive::run_event_loop`](../../src-tauri/crates/app/src/audio/wasapi_exclusive.rs) returns an `ExitReason`; `DeviceLost` covers a failed `wait_for_event` / `write_to_device`. Each is re-checked against the shutdown channel first so a deliberate teardown isn't mistaken for a failure.

Both then call the shared [`output::notify_device_lost`](../../src-tauri/crates/app/src/audio/output.rs) (park the player, emit `player:state` + `player:error`, sync the OS media controls) and [`output::schedule_device_rebuild`](../../src-tauri/crates/app/src/audio/output.rs) (300 ms backoff, then a same-device rebuild).

Two gates keep the recovery from thrashing:

- **`RebuildGate`** (`REBUILD_SETTLE_WINDOW`, 2 s) — one rebuild per burst of device errors. `begin_deliberate_output_change()` opens the same window around a mode toggle, because seizing the endpoint exclusively kicks the outgoing shared client off it and that self-inflicted `DeviceNotAvailable` would otherwise schedule a rebuild that undoes the switch.
- **`FlapWindow`** (`EXCLUSIVE_FLAP_THRESHOLD` / `EXCLUSIVE_FLAP_WINDOW`) — a device that resets on every exclusive grab gives up on exclusive for the rest of the session. Cleared by an explicit toggle or device switch.

Every failure path that ends with no output thread at all publishes `wasapi_exclusive_active = false` + the event before returning the error — a toggle describing a stream that no longer exists is the exact shape of #405.

## OS media controls

[`media_controls.rs`](../../src-tauri/crates/app/src/media_controls.rs) bridges the engine to [`souvlaki 0.8`](https://crates.io/crates/souvlaki):
Expand Down
Loading