Skip to content

Add macOS AirPlay support via AVKit#194

Closed
kristofferR wants to merge 16 commits into
mainfrom
feat/airplay-support
Closed

Add macOS AirPlay support via AVKit#194
kristofferR wants to merge 16 commits into
mainfrom
feat/airplay-support

Conversation

@kristofferR
Copy link
Copy Markdown
Owner

@kristofferR kristofferR commented May 7, 2026

Summary

Adds macOS-only AirPlay support so users can stream IPTV channels to an Apple TV. Cross-platform AirPlay 2 is blocked by needing to port Pair-Setup/Pair-Verify crypto to Rust (~9–13 days, ongoing maintenance burden — no maintained Rust AirPlay 2 sender crate exists). This PR delegates pairing/encryption/discovery to Apple's AVKit framework, which ships natively on macOS.

Important

Stacked on #192 (Chromecast support). Will rebase onto main after #192 lands. Review against the chromecast branch base.

Why this is more than a thin AVKit wrapper

The chromecast PR surfaced a long tail of IPTV-specific problems that apply identically to AirPlay:

  • Apple TV in URL-mode AirPlay does NOT forward AVURLAssetHTTPHeaderFieldsKey headers. ATV sends its own AppleCoreMedia/... UA. IPTV portals that filter by UA fail without going through the LAN proxy.
  • Single-credential contention is identical — ATV opens its own upstream connection; without proxy fan-out the local Mac AVPlayer and ATV race for the one slot.
  • HEAD + Range probing is required by Apple's HLS engine for fMP4 init segments.

So the cast proxy at `engine/cast_proxy.rs` is shared, parameterized on a `ReceiverProfile` enum that gates CORS / DASH+fMP4 / AAC-transcode / hvc1-tag — these are Chromecast-specific behaviors that AirPlay drops.

Scope

New files

  • `src-tauri/src/models/airplay.rs` — `AirPlayMediaRequest`, `AirPlaySession`, state enum
  • `src-tauri/src/engine/airplay.rs` — AVKit bridge (`AVPlayer` + `AVPlayerView` + `NSWindow`, KVO → `airplay://status` events)
  • `src-tauri/src/commands/airplay.rs` — `start_airplay`, `stop_airplay`, `get_airplay_status`
  • `src/hooks/useAirPlay.ts` — mirrors `useChromecast` (no devices)

Modified

  • `src-tauri/src/engine/cast_proxy.rs` → renamed `media_proxy.rs` with `ReceiverProfile` enum
  • `src-tauri/src/engine/chromecast.rs` — passes `ReceiverProfile::Chromecast`
  • `src-tauri/Cargo.toml` — macOS-only `objc2-foundation`, `objc2-app-kit`, `objc2-av-kit`, `objc2-av-foundation`, `block2`
  • `src-tauri/src/lib.rs`, `src-tauri/src/state.rs` — register commands behind `cfg(target_os = "macos")`
  • `src/components/CastMenu.tsx` — AirPlay row above device list (gated on macOS)
  • `src/components/StreamPlayer.tsx`, `ChannelTable.tsx`, `useStreamPlayer.ts` — extend `4e1ff42` redirect pattern to AirPlay sessions
  • `src/lib/{tauri,cast,types,platform}.ts` — invoke wrappers, helper, types, isMacOS gate

Implementation checklist

  • Add `AirPlayMediaRequest` / `AirPlaySession` (Rust + TS)
  • Add macOS-only Cargo deps for AVKit FFI
  • Generalize `cast_proxy.rs` → `media_proxy.rs` with `ReceiverProfile` enum
  • Build `engine/airplay.rs` (AVKit bridge)
  • Build `commands/airplay.rs`
  • Wire `state.rs` and `lib.rs` registration
  • Build `useAirPlay` hook + `buildAirPlayRequest` helper
  • Modify `CastMenu.tsx` and extend cast-active redirect to cover AirPlay
  • Real-device testing on Apple TV 4K

Test plan

Requires Apple TV 4K on the same LAN, single-credential IPTV provider:

  • Click AirPlay on HLS+AVC channel → AVPlayerView window opens, route picker shows ATV, video appears on TV
  • Confirm local Mac player paused (no double-fetch on upstream)
  • Repeat with HLS+HEVC, MPEG-TS+AVC, MPEG-TS+HEVC, EAC3-audio
  • Channel-switch mid-AirPlay via arrow keys → seamless or graceful re-load
  • Stop AirPlay → window closes, status emits `stopped`, can resume local play
  • Mutually-exclusive receivers: starting AirPlay tears down active Chromecast cleanly
  • `bun tauri build` (notarized) — verify proxy listener and AVKit work in production build
  • `bun run typecheck` and `cargo test` pass

Risks

Risk Mitigation
macOS App Sandbox blocks 0.0.0.0 LAN listener for notarized builds Verify against hardened-runtime on chromecast first, since same listener
ATV-specific HLS strictness silently fails (EXT-X-VERSION < 6 for fMP4, codec string mismatch) Real-device test matrix on every IPTV format
`replaceCurrentItem` mid-AirPlay channel-switch causes ATV to bounce to launcher Pre-buffer next item before swap, mirror chromecast's `bdba784` polish

Out of scope

  • Linux/Windows AirPlay sender (would need Pair-Verify crypto port)
  • Inline `AVRoutePickerView` overlay inside Tauri webview (z-order complexity)
  • HDR metadata pass-through verification

Summary by CodeRabbit

  • New Features

    • macOS AirPlay support: start/stop AirPlay, session status, and AirPlay routing integrated into player UI and cast menus.
  • Refactor

    • Unified media proxy to support both Chromecast and AirPlay with profile-aware streaming and manifest handling.
  • Bug Fixes

    • Improved local-stream cancellation to reduce leftover connections and smoother receiver handoffs.
  • Chores

    • CI install step adjusted to improve fuzz job reliability; macOS dependency additions for AirPlay.

Scaffold for macOS AirPlay support. Adds the request/session shape that
matches Chromecast's surface (minus device discovery — AirPlay route
selection is system-managed via AVPlayerView's HUD).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds macOS AirPlay streaming: generalizes the LAN media proxy to support ReceiverProfile (Chromecast/AirPlay), implements an AVKit-based AirPlay engine and Tauri commands, adds coordinated local-stream cancellation, and wires AirPlay controls and types through the React frontend with lifecycle locks ensuring mutual exclusion.

Changes

macOS AirPlay Integration

Layer / File(s) Summary
Build Dependencies
.github/workflows/ci.yml, src-tauri/Cargo.toml
Adds macOS Objective-C interop crates (objc2-*, block2) and changes parser-fuzz to install cargo-fuzz --version 0.13.1 without --locked (with inline comment).
Backend Data Models
src-tauri/src/models/airplay.rs, src-tauri/src/models/mod.rs, src/lib/types.ts
Adds AirPlaySessionState, AirPlaySession, and AirPlayMediaRequest and exposes the airplay model module.
Media Proxy Generalization
src-tauri/src/engine/media_proxy.rs
Refactors cast_proxy into a profile-aware media_proxy with ReceiverProfile, MediaProxyHandle, profile-driven HEVC/container selection, audio passthrough/transcode rules, and updated logging/CORS behavior.
Local Stream Cancellation
src-tauri/src/engine/stream_proxy.rs
Adds cancel_active_local_streams using CancellationToken and refactors per-connection logic into handle_streaming_connection with per-connection cancellation.
AirPlay Engine
src-tauri/src/engine/airplay.rs
Implements ActiveAirPlaySession, start_session, main-thread AVKit setup, idempotent async stop/stop_silent, window delegate teardown, and AIRPLAY_STATUS_EVENT.
State & Commands
src-tauri/src/state.rs, src-tauri/src/commands/{airplay,chromecast,mod}.rs, src-tauri/src/lib.rs, src-tauri/src/engine/mod.rs
Adds macOS-only AirPlayState and airplay_lifecycle_lock to AppState; replaces cast-specific proxy with MediaProxyHandle; adds start_airplay, stop_airplay, get_airplay_status commands and updates cast_to_device to drain AirPlay before Chromecast startup.
Frontend Types & IPC
src/lib/types.ts, src/lib/cast.ts, src/lib/tauri.ts
Adds TypeScript AirPlay types and helpers buildAirPlayRequest/isAirPlaySessionActive; adds platform-guarded startAirplay, stopAirplay, getAirplayStatus wrappers.
Frontend Hooks
src/hooks/useAirPlay.ts
Adds useAirPlay hook (macOS-gated) with initial status fetch, airplay://status subscription, and start/stop async methods calling IPC.
Frontend Integration
src/App.tsx, src/components/{CastMenu,ChannelTable,StreamPlayer,ThumbnailPanel}.tsx
Threads AirPlay through App → Sidebar → ThumbnailPanel → StreamPlayer/CastMenu; replaces isCasting checks with isReceiverActive; adds AirPlay UI rows/buttons, status badges, and restart-on-select behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped to code with nimble paws,
A window, player, and some laws;
Profiles twinned and locks aligned,
Streams now hand off, safe and kind.
From Rust to React, the rabbits cheer!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: adding macOS AirPlay support via AVKit, which is the main focus of this comprehensive pull request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/airplay-support

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kristofferR kristofferR marked this pull request as ready for review May 7, 2026 04:51
Renames engine/cast_proxy.rs to engine/media_proxy.rs and parameterizes
on a ReceiverProfile enum (Chromecast | AirPlay). The proxy now gates:

- CORS preflight (OPTIONS handler) on Chromecast only — AVPlayer is a
  native client and does not preflight.
- HEVC remux container: Cast continues to use DASH+fMP4 (Shaka path,
  required because CAFv3 MPL can't render fMP4 in HLS); AirPlay uses
  HLS+fMP4, the reference HEVC-fMP4-in-HLS platform per Apple HLS spec.
- hvc1 sample-entry tag override on Cast only (Apple TV accepts both
  hev1 and hvc1).
- Audio passthrough set: Cast keeps "AAC only", AirPlay accepts
  AAC/AC3/EAC3/MP3 natively per Apple HLS spec — only MP2 still
  transcodes.

All cast-domain plumbing reused as-is (token-gated 0.0.0.0 listener,
LAN-IP detection, manifest URI rewriting, ffmpeg lifecycle, reconnecting
upstream pump for HEVC, ffprobe codec gate, HEAD+Range, post-redirect
SSRF pin, path-traversal guard, cancellation propagation).
objc2 0.6 framework crates needed for the AVPlayer / AVPlayerView /
NSWindow plumbing in engine/airplay.rs.
cargo-fuzz 0.13.1's pinned Cargo.lock resolves rustix 0.36.5, which
uses `#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_*)]` —
internal compiler attributes that the current nightly toolchain
rejects with "attributes starting with `rustc` are reserved for use
by the `rustc` compiler". Letting cargo pick a newer 0.36.x patch
unblocks the install.
Builds an AVURLAsset + AVPlayer + AVPlayerView in an NSWindow whose HUD
exposes the AirPlay route picker. Local playback streams in the window
until the user picks an Apple TV; AVPlayer then transparently swaps to
external playback.

The proxy URL is fed to the asset (not the upstream). Apple TV in
URL-mode AirPlay does not forward AVURLAssetHTTPHeaderFieldsKey, so the
proxy remains essential for UA injection and single-credential fan-out.

All AVFoundation/AppKit calls are dispatched to the AppKit main thread
via run_on_main_thread; Retained handles ride in MainOnly to satisfy
Send/Sync without leaking the !Send invariant outside the type.
Adds macOS-only AirPlayState (session + proxy) plus airplay_lifecycle_lock,
mirroring the Chromecast pair. The lifecycle lock serializes start/stop so
concurrent calls cannot orphan a session or proxy.
start_airplay, stop_airplay, get_airplay_status mirror the cast command
shape behind cfg(target_os = "macos"). The lifecycle lock serializes
start/stop, and starting AirPlay tears down any live Chromecast first
(cast_to_device reciprocates) so the single upstream slot only services
one external receiver at a time.
useAirPlay mirrors useChromecast: subscribes to airplay://status, exposes
start/stop, gates on detectPlatform() === "macos" via available so the
hook is a no-op on Windows/Linux (the command isn't registered there
either). buildAirPlayRequest reuses detectStreamKind so the proxy gets
the same hls/mpeg_ts hint the cast path uses.
Inline mode shows an AirPlay row above the Cast row when airplay is
available (macOS); popover mode adds the same affordance at the top.
The AVPlayerView HUD owns the route picker once playback begins, so the
in-app control just needs an Open/Stop AirPlay button — no device list.
Threads the shared useAirPlay hook from App.tsx through
SelectedChannelSidebar -> ThumbnailPanel -> StreamPlayer/CastMenu,
mirroring the existing Chromecast plumbing. handlePlayInApp now
redirects to an active AirPlay session the same way it already
redirected to an active Cast session, so arrow-key channel-surfing
re-targets the AirPlay receiver instead of starting a local player.

Renames ChannelTable's isCasting prop to isReceiverActive (the auto-
open debounce semantics are identical for both receivers) and updates
App.tsx to pass isCasting || isAirPlaying.

StreamPlayer renders a top-left "AirPlaying to receiver" /
"AirPlay window open" badge mirroring the Cast badge, and forwards
the AirPlay hook + request into CastMenu so the popover offers
Start/Stop AirPlay alongside Chromecast device picking.
Base automatically changed from feat/chromecast-support to main May 7, 2026 11:15
@kristofferR
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src-tauri/src/engine/media_proxy.rs (1)

115-134: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

RemuxState.child is always None — dead code in both cleanup paths.

child is moved into the tokio::spawn worker task at line 587 (let mut child = cmd.spawn()?). RemuxState is then populated at line 641 with child: None, so child is None for the entire lifetime of the RemuxState. The if let Some(c) = child.as_mut() branches in both cleanup_blocking (line 127) and cleanup_remux_async (line 696) are unreachable, and the doc comment "We hold it for diagnostic logging" (line 118) is inaccurate.

The worker task already owns child process management exclusively (via the cancel token). Remove the child field from RemuxState and update both cleanup functions accordingly.

♻️ Proposed fix
 struct RemuxState {
     tmpdir: PathBuf,
-    /// ffmpeg child handle. We hold it for diagnostic logging; the worker task
-    /// is responsible for waiting on it.
-    child: Option<Child>,
 }

 impl RemuxState {
     fn cleanup_blocking(self) {
-        let RemuxState { tmpdir, mut child } = self;
-        if let Some(c) = child.as_mut() {
-            let _ = c.start_kill();
-        }
+        let RemuxState { tmpdir } = self;
         let _ = std::fs::remove_dir_all(&tmpdir);
     }
 }
 async fn cleanup_remux_async(state: RemuxState) {
-    let RemuxState { tmpdir, mut child } = state;
-    if let Some(c) = child.as_mut() {
-        graceful_kill(c, GRACEFUL_KILL_TIMEOUT).await;
-    }
+    let RemuxState { tmpdir } = state;
     let _ = tokio::fs::remove_dir_all(&tmpdir).await;
 }
     *guard = Some(RemuxState {
         tmpdir: tmpdir.clone(),
-        child: None,
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/engine/media_proxy.rs` around lines 115 - 134, RemuxState
currently keeps a child: Option<Child> that is never set (the worker owns the
Child via tokio::spawn), so remove the dead field and update cleanup paths:
delete the child field from struct RemuxState and remove any logic that checks
or kills it in cleanup_blocking and cleanup_remux_async; also update the struct
construction sites (where RemuxState is instantiated with child: None) to stop
providing that field and adjust the doc comment that claims we hold the child
for diagnostics. Ensure all references to child, as_mut, start_kill, and related
handling are removed or refactored to use the worker task's cancellation
behavior instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 166-170: The workflow currently runs the unpinned installer
command "cargo install cargo-fuzz", making CI flaky as upstream releases change;
replace that command with a pinned install (e.g., use "cargo install cargo-fuzz
--version <x.y.z>") referencing a known-good release for your CI, so fuzz smoke
tests are reproducible across runs; keep the rationale of not using --locked but
ensure the version is explicitly set for stability.

In `@src-tauri/src/commands/airplay.rs`:
- Around line 28-44: start_airplay currently locks airplay_lifecycle_lock then
cast_lifecycle_lock causing an ABBA deadlock with cast_to_device which acquires
them in the opposite order; reorder the locking in start_airplay to acquire
cast_lifecycle_lock before airplay_lifecycle_lock (or replace both with a single
unified receiver_lifecycle_lock) so both paths use the same canonical order;
update the lock acquisition in the start_airplay function and any related
cleanup paths that reference airplay_lifecycle_lock and cast_lifecycle_lock to
match the new ordering or unified lock, and adjust any comments to reflect the
new lock discipline.

In `@src-tauri/src/commands/chromecast.rs`:
- Around line 29-37: cast_to_device currently locks cast_lifecycle_lock then (on
macOS) airplay_lifecycle_lock, while start_airplay locks airplay_lifecycle_lock
then cast_lifecycle_lock, creating a deadlock risk; change cast_to_device to
acquire the locks in the same order as start_airplay by taking
airplay_lifecycle_lock first (under #[cfg(target_os = "macos")] where
applicable) and only then acquiring cast_lifecycle_lock (i.e., reorder the lock
acquisition around the blocks referencing airplay_lifecycle_lock and
cast_lifecycle_lock in cast_to_device to match start_airplay’s acquisition
order).

In `@src-tauri/src/engine/airplay.rs`:
- Around line 77-101: stop_inner currently moves MainOnly<Retained<...>>
(window, player) into a closure and discards the Result of run_on_main_thread,
risking an off-main-thread drop if dispatch fails; fix by wrapping window and
player in core::mem::ManuallyDrop before calling app.run_on_main_thread, move
those ManuallyDrop wrappers into the closure and explicitly call
ManuallyDrop::drop inside the closure so the ObjC releases happen on the main
thread, and check the Result from app.run_on_main_thread so you can retry or
schedule a guaranteed main-thread drop path on Err; key symbols: stop_inner,
run_on_main_thread, ActiveAirPlaySession, snapshot, window, player, Retained,
MainOnly, and use ManuallyDrop to prevent accidental off-thread drop.
- Around line 197-213: The window lacks an NSWindowDelegate so closing via the
title bar never triggers cleanup; attach an NSWindowDelegate implementation to
the created NSWindow (after setReleasedWhenClosed(false)) that implements
windowWillClose: and calls the existing stop_airplay (or directly invokes
stop_inner()) to perform idempotent cleanup (pause AVPlayer, clear session, drop
refs). Locate the NSWindow creation block around
NSWindow::initWithContentRect_styleMask_backing_defer and set the delegate (or
create a small delegate struct/object) that calls stop_airplay/stop_inner() on
windowWillClose: so user-triggered closes properly stop the session and update
AppState.

In `@src/App.tsx`:
- Around line 2537-2539: The fire-and-forget call to
airplay.start(buildAirPlayRequest(result)) can reject and cause unhandled
promise rejections; wrap it with a promise catch to surface failures as
controlled UI errors. Update the block where
isAirPlaySessionActive(airplay.session) is true to call
airplay.start(buildAirPlayRequest(result)).catch(err => /* surface to UI */) and
invoke your existing UI error handling (e.g., set state, call
showError/dispatch) so redirect failures are logged/shown instead of unhandled.
Ensure the catch references the same buildAirPlayRequest and airplay.start
symbols and does not swallow the error silently.

In `@src/components/CastMenu.tsx`:
- Around line 22-28: CastMenu defines a local UseAirPlayShape duplicate of the
hook's type; remove that local interface and import the canonical
UseAirPlayResult from the hook (useAirPlay.ts) instead, then replace all
references to UseAirPlayShape in the CastMenu component with UseAirPlayResult
(e.g., props, state, or function signatures) so the component uses the exported
type from useAirPlay and stays in sync with future changes.

In `@src/hooks/useAirPlay.ts`:
- Around line 64-70: The async initialization in useAirPlay's IIFE calls
detectPlatform() without handling rejections; wrap the detectPlatform() call (or
the whole IIFE body) in a try/catch and on error return early (and optionally
log) to avoid unhandled promise rejections and partial setup; ensure you still
check mounted, cancelledRef.current and only call setAvailable(true) when
platform === "macos" after a successful detectPlatform() resolution.

In `@src/lib/tauri.ts`:
- Around line 313-325: Guard the macOS-only IPC invocations in startAirplay,
stopAirplay and getAirplayStatus by checking the runtime platform before calling
invoke; if the platform is not macOS (darwin) return an explicit handled
value/error (e.g. reject startAirplay/stopAirplay with a clear Error like
"AirPlay not supported on this platform" and have getAirplayStatus resolve to
null) so invoke("start_airplay" | "stop_airplay" | "get_airplay_status") is
never called on non-macOS runtimes.

---

Outside diff comments:
In `@src-tauri/src/engine/media_proxy.rs`:
- Around line 115-134: RemuxState currently keeps a child: Option<Child> that is
never set (the worker owns the Child via tokio::spawn), so remove the dead field
and update cleanup paths: delete the child field from struct RemuxState and
remove any logic that checks or kills it in cleanup_blocking and
cleanup_remux_async; also update the struct construction sites (where RemuxState
is instantiated with child: None) to stop providing that field and adjust the
doc comment that claims we hold the child for diagnostics. Ensure all references
to child, as_mut, start_kill, and related handling are removed or refactored to
use the worker task's cancellation behavior instead.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: b77966e2-2cdc-4a42-b999-8970f37eba4e

📥 Commits

Reviewing files that changed from the base of the PR and between ff08a12 and 9634e83.

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • .github/workflows/ci.yml
  • src-tauri/Cargo.toml
  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/commands/chromecast.rs
  • src-tauri/src/commands/mod.rs
  • src-tauri/src/engine/airplay.rs
  • src-tauri/src/engine/media_proxy.rs
  • src-tauri/src/engine/mod.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/models/airplay.rs
  • src-tauri/src/models/mod.rs
  • src-tauri/src/state.rs
  • src/App.tsx
  • src/components/CastMenu.tsx
  • src/components/ChannelTable.tsx
  • src/components/StreamPlayer.tsx
  • src/components/ThumbnailPanel.tsx
  • src/hooks/useAirPlay.ts
  • src/lib/cast.ts
  • src/lib/tauri.ts
  • src/lib/types.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (7)
src-tauri/src/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary

Files:

  • src-tauri/src/models/mod.rs
  • src-tauri/src/commands/mod.rs
  • src-tauri/src/engine/mod.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/engine/airplay.rs
  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/models/airplay.rs
  • src-tauri/src/state.rs
  • src-tauri/src/commands/chromecast.rs
  • src-tauri/src/engine/media_proxy.rs
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Define agent interfaces and implementations in TypeScript for agent systems
Use clear naming conventions for agent classes and methods
Document agent behavior and responsibilities in comments or docstrings
Implement error handling in agent methods

Files:

  • src/lib/tauri.ts
  • src/lib/cast.ts
  • src/App.tsx
  • src/components/ChannelTable.tsx
  • src/hooks/useAirPlay.ts
  • src/components/StreamPlayer.tsx
  • src/lib/types.ts
  • src/components/ThumbnailPanel.tsx
  • src/components/CastMenu.tsx
src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

TypeScript code should use strict mode and enforce no unused locals/params

Files:

  • src/lib/tauri.ts
  • src/lib/cast.ts
  • src/App.tsx
  • src/components/ChannelTable.tsx
  • src/hooks/useAirPlay.ts
  • src/components/StreamPlayer.tsx
  • src/lib/types.ts
  • src/components/ThumbnailPanel.tsx
  • src/components/CastMenu.tsx
src/lib/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Use Tauri IPC commands for frontend-backend communication instead of direct HTTP calls

Files:

  • src/lib/tauri.ts
  • src/lib/cast.ts
  • src/lib/types.ts
src/components/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/components/**/*.{ts,tsx}: Use functional components with hooks for React components
Use Tailwind CSS for styling components, no CSS-in-JS

Files:

  • src/components/ChannelTable.tsx
  • src/components/StreamPlayer.tsx
  • src/components/ThumbnailPanel.tsx
  • src/components/CastMenu.tsx
src/components/ChannelTable.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Use @tanstack/react-virtual for virtualizing tables with 1000+ items

Files:

  • src/components/ChannelTable.tsx
src/lib/types.ts

📄 CodeRabbit inference engine (AGENTS.md)

Define and maintain data types in lib/types.ts that mirror Rust models

Files:

  • src/lib/types.ts
🔇 Additional comments (7)
src-tauri/src/models/airplay.rs (1)

5-33: Model definitions are consistent and IPC-ready.

Serde casing strategy and Rust↔TS shape mapping look clean here.

src-tauri/src/models/mod.rs (1)

1-1: AirPlay model module export looks correct.

src-tauri/src/commands/mod.rs (1)

1-2: macOS command gating is correctly scoped.

src-tauri/Cargo.toml (1)

59-63: macOS-only dependency additions are correctly target-scoped.

src-tauri/src/engine/mod.rs (1)

1-5: Engine module wiring is coherent for receiver split + macOS AirPlay.

src/components/ThumbnailPanel.tsx (1)

401-422: Receiver-session visibility logic is a solid UX safeguard.

Keeping controls visible while either cast session is active is the right behavior.

src/lib/types.ts (1)

462-483: AirPlay TS models look correctly aligned with backend contract.

Comment thread .github/workflows/ci.yml Outdated
Comment thread src-tauri/src/commands/airplay.rs
Comment thread src-tauri/src/commands/chromecast.rs
Comment thread src-tauri/src/engine/airplay.rs
Comment thread src-tauri/src/engine/airplay.rs
Comment thread src/App.tsx
Comment on lines +2537 to +2539
if (isAirPlaySessionActive(airplay.session)) {
void airplay.start(buildAirPlayRequest(result));
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle AirPlay redirect failures to prevent unhandled promise rejections.

At Line 2538, airplay.start(...) is invoked fire-and-forget even though the hook method can reject. Add a catch so failed redirects surface as controlled UI errors.

Proposed fix
       if (isAirPlaySessionActive(airplay.session)) {
-        void airplay.start(buildAirPlayRequest(result));
+        void airplay.start(buildAirPlayRequest(result)).catch((err) => {
+          logger.error("[AirPlay] Redirect failed:", errorToString(err));
+          getStore().setPlaybackError(errorToString(err));
+        });
         return;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isAirPlaySessionActive(airplay.session)) {
void airplay.start(buildAirPlayRequest(result));
return;
if (isAirPlaySessionActive(airplay.session)) {
void airplay.start(buildAirPlayRequest(result)).catch((err) => {
logger.error("[AirPlay] Redirect failed:", errorToString(err));
getStore().setPlaybackError(errorToString(err));
});
return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/App.tsx` around lines 2537 - 2539, The fire-and-forget call to
airplay.start(buildAirPlayRequest(result)) can reject and cause unhandled
promise rejections; wrap it with a promise catch to surface failures as
controlled UI errors. Update the block where
isAirPlaySessionActive(airplay.session) is true to call
airplay.start(buildAirPlayRequest(result)).catch(err => /* surface to UI */) and
invoke your existing UI error handling (e.g., set state, call
showError/dispatch) so redirect failures are logged/shown instead of unhandled.
Ensure the catch references the same buildAirPlayRequest and airplay.start
symbols and does not swallow the error silently.

Comment thread src/components/CastMenu.tsx Outdated
Comment thread src/hooks/useAirPlay.ts
Comment thread src/lib/tauri.ts
The two commands acquired the cast/airplay lifecycle locks in opposite
orders: start_airplay took airplay first then cast, while cast_to_device
already took cast first then airplay. Concurrent invocations could
circular-wait indefinitely.

Establish a canonical order — cast lifecycle lock first, then airplay
lifecycle lock — by flipping the order in start_airplay to match
cast_to_device. The teardown semantics (mutual exclusion: kill any live
cast before opening a new AirPlay upstream slot) are unchanged.

Also extract the airplay teardown body into a `teardown_airplay`
helper so the upcoming NSWindowDelegate (next commit) can route a user-
triggered window close through the same idempotent shutdown path the
explicit stop_airplay command uses.
Two related safety issues in the AirPlay engine:

1. Without an NSWindowDelegate, closing the AVPlayer window via the
   title-bar "×" left the session "playing" in app state — AVPlayer
   kept consuming the upstream proxy slot, the cast-redirect gate
   stayed armed, and the user couldn't open a new local stream until
   they manually clicked stop. Add a delegate that routes
   windowWillClose through the same teardown_airplay path the
   explicit stop_airplay command uses. The delegate is retained on
   the session struct (NSWindow.setDelegate is weak), and a
   per-instance atomic guards against double-fire when our own
   stop_inner closes the window programmatically.

2. stop_inner moved the MainOnly<Retained<...>> handles into a
   run_on_main_thread closure but discarded the dispatch Result. If
   the closure failed to dispatch (event loop torn down during app
   shutdown) the closure was dropped on the calling tokio worker,
   which in turn dropped the Retained<NSObject> handles off the main
   thread — directly violating the MainOnly invariant documented in
   the file header. Wrap the handles in ManuallyDrop, take ownership
   back inside the closure, and on dispatch failure log + leak rather
   than release off-thread.
The AirPlay Tauri commands are gated on macOS in lib.rs, so calling them
on Windows/Linux returns an opaque "command not found" error from the
webview layer. Add a synchronous platform check at the wrapper level so:

- startAirplay throws a clearly-named error on non-mac.
- stopAirplay / getAirplayStatus return early with a sensible default.

Also harden useAirPlay's effect: a detectPlatform() rejection (e.g.
tauri-plugin-os not yet ready) was escaping the async IIFE as an
unhandled promise rejection. Catch it and treat as un-`available` —
the hook stays inert rather than crashing the effect.
CastMenu was duplicating useAirPlay's return type as a local
UseAirPlayShape interface. Import UseAirPlayResult directly so future
hook changes flow through automatically (StreamPlayer / ThumbnailPanel
already use the hook's exported type).

In App.tsx the redirect branches in handlePlayInApp invoked
chromecast.cast / airplay.start as fire-and-forget. Both hooks already
mirror failures into their own `error` state (which CastMenu renders),
but the rejection still escaped here as an unhandled promise. Add an
empty .catch on each branch — the user-facing error surface is the
hook's error, not this call site.
Unpinned `cargo install cargo-fuzz` lets upstream releases drift CI
behavior between PR runs. Pin to 0.13.1 (the current stable as of
this branch) so fuzz smoke tests are reproducible. Keep the
no-`--locked` rationale comment intact — that workaround is still
needed because cargo-fuzz's pinned Cargo.lock pulls a rustix that
won't compile on current nightlies.
@kristofferR
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useAirPlay.ts`:
- Around line 31-37: In the catch block inside useAirPlay (where you currently
call setError(String(err)) and rethrow), replace String(err) with a robust
converter by importing and using the existing errorToString helper from App.tsx
(or hoist errorToString into a shared util) so object-shaped errors (e.g., {
message }) render correctly; update the import/exports or move the helper to a
common module and call setError(errorToString(err)) while keeping the
cancellation guard and rethrow logic intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 80c7ca51-3848-46c3-b66b-28f9f2c28b65

📥 Commits

Reviewing files that changed from the base of the PR and between 9634e83 and 366dfb9.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/engine/airplay.rs
  • src/App.tsx
  • src/components/CastMenu.tsx
  • src/hooks/useAirPlay.ts
  • src/lib/tauri.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
src-tauri/src/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary

Files:

  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/engine/airplay.rs
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Define agent interfaces and implementations in TypeScript for agent systems
Use clear naming conventions for agent classes and methods
Document agent behavior and responsibilities in comments or docstrings
Implement error handling in agent methods

Files:

  • src/components/CastMenu.tsx
  • src/lib/tauri.ts
  • src/App.tsx
  • src/hooks/useAirPlay.ts
src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

TypeScript code should use strict mode and enforce no unused locals/params

Files:

  • src/components/CastMenu.tsx
  • src/lib/tauri.ts
  • src/App.tsx
  • src/hooks/useAirPlay.ts
src/components/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

src/components/**/*.{ts,tsx}: Use functional components with hooks for React components
Use Tailwind CSS for styling components, no CSS-in-JS

Files:

  • src/components/CastMenu.tsx
src/lib/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

Use Tauri IPC commands for frontend-backend communication instead of direct HTTP calls

Files:

  • src/lib/tauri.ts
🔇 Additional comments (9)
.github/workflows/ci.yml (1)

166-172: Good CI hardening and clear install rationale.

Pinning cargo-fuzz while explicitly documenting why --locked is avoided makes the fuzz job reproducible and easier to maintain.

src/App.tsx (1)

2536-2549: LGTM — addresses prior unhandled-rejection concern.

The .catch(() => {}) swallow plus inline comment correctly mirror the cast branch's pattern, relying on airplay.error for UI surfacing. The early return (without local-play fallback) is consistent with the PR's documented replaceCurrentItem/single-upstream-slot rationale.

src/lib/tauri.ts (1)

313-342: LGTM — platform guards correctly added.

ensureAirPlaySupported provides a clear error for startAirplay, while stopAirplay/getAirplayStatus correctly no-op/return-null on non-macOS. This addresses the prior concern about unknown-command failures from non-mac call paths.

src/components/CastMenu.tsx (1)

10-10: LGTM — UseAirPlayResult import and AirPlay flow look correct.

Importing the canonical UseAirPlayResult removes the duplicate type definition raised in prior review. The popover/inline AirPlay sections both correctly gate on airplayAvailable and mirror the cast-flow pattern (fire onAirPlayStart before awaiting; surface errors via airplay.error).

Also applies to: 93-97, 151-172

src-tauri/src/commands/airplay.rs (2)

26-77: LGTM — ABBA deadlock fix verified.

Lock acquisition order (cast_lifecycle_lockairplay_lifecycle_lock) now matches cast_to_device, eliminating the prior deadlock risk. The error path correctly shuts down the freshly created proxy on session-start failure (line 75), and the chromecast preemption is sequenced after locks are held.


100-121: LGTM — teardown_airplay is correctly idempotent.

Taking session/proxy out under the lifecycle lock makes repeated calls safe (second call sees None and no-ops), which is exactly what the windowWillClose: delegate path needs.

src-tauri/src/engine/airplay.rs (2)

85-127: LGTM — ManuallyDrop correctly preserves the MainOnly invariant.

The run_on_main_thread error branch intentionally leaks the Cocoa handles rather than dropping them off-thread, matching the safety contract documented at lines 41–44. The stopped swap-and-return at line 86 plus single-consumption (self-by-value) make this fully idempotent.


134-175: LGTM — NSWindowDelegate plus strong ref correctly handle user-driven close.

The fired AtomicBool prevents the delegate from re-spawning teardown when stop_inner programmatically closes the window, and the strong _delegate field on ActiveAirPlaySession correctly outlives NSWindow's weak delegate reference. Spawning the tokio task from window_will_close avoids holding the main thread on the lifecycle lock.

Also applies to: 307-321

src/hooks/useAirPlay.ts (1)

82-96: ⚡ Quick win

No issue — AirPlaySessionState variants serialize correctly as lowercase strings.

The AirPlaySessionState enum in src-tauri/src/models/airplay.rs uses #[serde(rename_all = "snake_case")], which serializes the Stopped and Error variants as "stopped" and "error" respectively. The TS comparisons at lines 85 and 91 are correct and will properly clear the session and error state as intended.

			> Likely an incorrect or invalid review comment.

Comment thread src/hooks/useAirPlay.ts
Comment on lines +31 to +37
} catch (err) {
if (!cancelledRef.current) {
setError(String(err));
setSession(null);
}
throw err;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Past concerns addressed; minor: String(err) produces [object Object] for plain-object errors.

Tauri rejections often surface as strings, but for object-shaped errors ({ message: ... }) String(err) yields "[object Object]". Consider reusing the existing errorToString helper from App.tsx (or hoisting it) for consistent error display. The detectPlatform() catch and cancellation guards correctly address prior review feedback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useAirPlay.ts` around lines 31 - 37, In the catch block inside
useAirPlay (where you currently call setError(String(err)) and rethrow), replace
String(err) with a robust converter by importing and using the existing
errorToString helper from App.tsx (or hoist errorToString into a shared util) so
object-shaped errors (e.g., { message }) render correctly; update the
import/exports or move the helper to a common module and call
setError(errorToString(err)) while keeping the cancellation guard and rethrow
logic intact.

Single-credential IPTV portals reject the receiver with HTTP 458 when
the local stream player still holds the upstream slot, aborting the
remux before its manifest is ready. Add a shared CancellationToken in
AppState that each localhost streaming-proxy connection races against,
and have start_airplay / cast_to_device cancel + briefly wait so the
upstream session is freed before ffmpeg connects.

Also rename the wait_for_manifest cancellation error from "Cast remux"
to "Receiver remux" now that the proxy serves both Cast and AirPlay.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/commands/chromecast.rs`:
- Around line 76-87: The same-device hot-swap path in same_device_take currently
starts the new media_proxy::start while prior_proxy is still alive, which allows
overlapping MPEG-TS upstream sessions and can trigger HTTP 458; change the
sequence so the old receiver/proxy is torn down before starting a new remux for
CastStreamKind::MpegTs (either call the prior_proxy shutdown/stop method or
drop/prioritize cancellation of the prior_proxy prior to invoking
media_proxy::start, or alternatively make media_proxy::start lazy for MpegTs
until after the receiver switch completes) so no two upstream sessions overlap
during the handoff.

In `@src-tauri/src/engine/stream_proxy.rs`:
- Around line 39-49: The current swap installs a fresh CancellationToken into
state.local_stream_cancel before cancelling the old one, allowing reconnecting
local players to capture the fresh token during the LOCAL_STREAM_CANCEL_GRACE
window; change the logic in cancel_active_local_streams so new connections are
blocked during the grace period — either by first cancelling the old token (call
old.cancel()), then await LOCAL_STREAM_CANCEL_GRACE, and only after the sleep
replace the state.local_stream_cancel with a new CancellationToken, or else
replace the state token with a pre-cancelled “blocker” token (a
CancellationToken that's already cancelled) to prevent
current_local_stream_cancel snapshots in handle_streaming_connection from being
usable until after the grace window, then swap in a fresh CancellationToken when
the grace period ends; update references to local_stream_cancel,
CancellationToken::new(), old.cancel(), LOCAL_STREAM_CANCEL_GRACE,
current_local_stream_cancel, and handle_streaming_connection accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4f89abb9-8e07-4849-9e4e-94cb5a5ff3ea

📥 Commits

Reviewing files that changed from the base of the PR and between 366dfb9 and 34ae486.

📒 Files selected for processing (5)
  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/commands/chromecast.rs
  • src-tauri/src/engine/media_proxy.rs
  • src-tauri/src/engine/stream_proxy.rs
  • src-tauri/src/state.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Build (windows-latest)
  • GitHub Check: Parser Fuzz Smoke
  • GitHub Check: Build (ubuntu-22.04)
  • GitHub Check: Build (macos-latest)
  • GitHub Check: Rust Tests
🧰 Additional context used
📓 Path-based instructions (1)
src-tauri/src/**/*.rs

📄 CodeRabbit inference engine (AGENTS.md)

src-tauri/src/**/*.rs: Rust code should use snake_case naming convention and 4-space indentation
Use thiserror crate for error types in Rust
Use serde for serialization in Rust
Before launching bun tauri dev after Rust code changes, run cargo clean -p iptv-checker && cargo build in src-tauri/ to ensure a fresh binary

Files:

  • src-tauri/src/commands/chromecast.rs
  • src-tauri/src/state.rs
  • src-tauri/src/commands/airplay.rs
  • src-tauri/src/engine/stream_proxy.rs
  • src-tauri/src/engine/media_proxy.rs

Comment on lines 76 to 87
if let Some((mut active, prior_proxy)) = same_device_take {
match cast_proxy::start(
// Same as the fresh-launch path below: drop any local-player upstream
// fetch so single-credential IPTV portals don't reject ffmpeg's load
// with HTTP 458.
stream_proxy::cancel_active_local_streams(state.inner()).await;

match media_proxy::start(
app.clone(),
request.original_url.clone(),
request.stream_kind,
ReceiverProfile::Chromecast,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid overlapping the old and new MPEG-TS upstream sessions during same-device hot-swap.

Line 82 starts the new media_proxy while the old prior_proxy stays alive until Lines 103-119. For CastStreamKind::MpegTs, that means ffmpeg can open the new upstream before the old receiver path has released its slot, so same-device channel changes can still hit the HTTP 458 failure this PR is mitigating. Either tear down the old proxy before starting the new remux, or make remux startup lazy until after the receiver has switched.

Also applies to: 103-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/commands/chromecast.rs` around lines 76 - 87, The same-device
hot-swap path in same_device_take currently starts the new media_proxy::start
while prior_proxy is still alive, which allows overlapping MPEG-TS upstream
sessions and can trigger HTTP 458; change the sequence so the old receiver/proxy
is torn down before starting a new remux for CastStreamKind::MpegTs (either call
the prior_proxy shutdown/stop method or drop/prioritize cancellation of the
prior_proxy prior to invoking media_proxy::start, or alternatively make
media_proxy::start lazy for MpegTs until after the receiver switch completes) so
no two upstream sessions overlap during the handoff.

Comment on lines +39 to +49
pub async fn cancel_active_local_streams(state: &AppState) {
let old = {
let mut guard = state.local_stream_cancel.lock().await;
std::mem::replace(&mut *guard, CancellationToken::new())
};
old.cancel();
log::info!(
"[StreamProxy] Released upstream slot (waiting {}ms for server cleanup)",
LOCAL_STREAM_CANCEL_GRACE.as_millis()
);
tokio::time::sleep(LOCAL_STREAM_CANCEL_GRACE).await;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The token swap still allows local-player reconnects during the grace window.

Lines 40-42 install a fresh CancellationToken before cancelling the old one, and Lines 593-606 make each new connection capture whatever token is current when it is accepted. A local player that reconnects during the 250 ms sleep will grab the fresh token and keep the upstream slot alive, so Chromecast/AirPlay can still lose the race with HTTP 458. This needs a temporary “block new local streams” phase, or the token reset has to happen after the protected window closes.

#!/bin/bash
set -euo pipefail

echo "Inspect replace/cancel ordering in stream_proxy:"
rg -n -C3 'cancel_active_local_streams|std::mem::replace|old\.cancel|LOCAL_STREAM_CANCEL_GRACE' \
  src-tauri/src/engine/stream_proxy.rs

echo
echo "Inspect where new connections snapshot the current token:"
rg -n -C3 'current_local_stream_cancel|cancel\.cancelled\(\)|handle_streaming_connection' \
  src-tauri/src/engine/stream_proxy.rs

echo
echo "Inspect receiver call sites that rely on this handoff:"
rg -n -C2 'cancel_active_local_streams' \
  src-tauri/src/commands/chromecast.rs \
  src-tauri/src/commands/airplay.rs

Also applies to: 52-54, 587-606

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/engine/stream_proxy.rs` around lines 39 - 49, The current swap
installs a fresh CancellationToken into state.local_stream_cancel before
cancelling the old one, allowing reconnecting local players to capture the fresh
token during the LOCAL_STREAM_CANCEL_GRACE window; change the logic in
cancel_active_local_streams so new connections are blocked during the grace
period — either by first cancelling the old token (call old.cancel()), then
await LOCAL_STREAM_CANCEL_GRACE, and only after the sleep replace the
state.local_stream_cancel with a new CancellationToken, or else replace the
state token with a pre-cancelled “blocker” token (a CancellationToken that's
already cancelled) to prevent current_local_stream_cancel snapshots in
handle_streaming_connection from being usable until after the grace window, then
swap in a fresh CancellationToken when the grace period ends; update references
to local_stream_cancel, CancellationToken::new(), old.cancel(),
LOCAL_STREAM_CANCEL_GRACE, current_local_stream_cancel, and
handle_streaming_connection accordingly.

@kristofferR
Copy link
Copy Markdown
Owner Author

I've decided that I don't want this, Chromecast support is enough.

@kristofferR kristofferR closed this May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant