Skip to content

feat(airplay): AirPlay on macOS, with HLS from the desktop converter - #338

Merged
MaxMB15 merged 26 commits into
devfrom
feat/airplay-macos
Oct 21, 2026
Merged

MaxMB15 merged 26 commits into
devfrom
feat/airplay-macos

Conversation

@MaxMB15

@MaxMB15 MaxMB15 commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

What this changes

Step 4 of the AirPlay track: AirPlay from the Mac, and HLS output from cast's desktop converter. Branched from dev at 3f41bba, after #323 and #329, with dev merged in again after #337.

The plugin's macOS backend (crates/tauri-plugin-mpv/src/airplay/macos.rs)

  • AVPlayer, AVRoutePickerView and AVRouteDetector through objc2-av-foundation, objc2-av-kit and objc2-core-media 0.3, the same objc2 family macos.rs uses. Everything runs on the main queue, and the host sits in MainThreadBound.
  • It reports to the same Tracker as iOS, so the state, the reconnect and lost timing, and the error codes are the same.
  • There's no KVO. While something needs watching, a timer reads AVPlayer's state four times a second, and the tracker drops reports that change nothing.
  • macOS never says which receiver a player's route went to, or whether there is one: there's no AVAudioSession and no public current route, only isExternalPlaybackActive, and only once an item plays. So:
    • When the picker closes, the route counts as picked. The picker is hidden whenever it isn't open, so it never takes the page's clicks.
    • AVPlayer is muted whenever external playback isn't active, and when an item stops. The rules are in airplay/pick.rs, which the Linux tests reach.
    • Until the video is on a receiver, the item shows as opening at the position it started from, so no resume point moves on.
    • If the video isn't on a receiver 10 s after the item is ready, AVPlayer stops and the route clears. The player stops AirPlay and plays the item here again from where it was sent, with no error. This is a new notPicked flag on the AirPlay error, like unsupported. The shared PlaybackError codes are unchanged.
    • The Mac's reports carry their load, and one about an earlier load is dropped.
  • The route belongs to the player, so the Mac's own sound never moves. On a Mac, Play here just stops AirPlay, and the panel leaves out "Choose this iPhone". Play and pause do nothing while the TV is lost.
  • Subtitle and audio track choice aren't on macOS yet: the reports list none, so the panel offers none.

HLS from the desktop converter

  • mvp_core::convert::output_args takes an Output: Matroska, which is what Cast gets and is unchanged, or MpegTs.
  • mvp_core::convert::ts::TsSegmenter cuts the MPEG-TS pipe into segments:
    • at video random access points at least 2 s apart, or at audio packets when there's no video
    • at the next video packet past 8 s or 32 MB
    • at the next audio or video packet past 8 s or 32 MB, so a picture that stalls still makes segments
    • at a clock jump of more than 2 s either way, with EXT-X-DISCONTINUITY before the next segment, and EXT-X-DISCONTINUITY-SEQUENCE once one leaves the window. No EXTINF is longer than the target.
    • each segment starts with the PAT and PMT, with their continuity counters renumbered
    • each timestamp goes in the 33-bit wrap nearest the clock, so a B-frame across a wrap stays in place
    • it reads the CODECS profile from the first SPS
  • Why not -f hls: it writes files and never waits, so a paused receiver would lose its segments. Reading the pipe holds ffmpeg back.
  • apps/player/src-tauri/src/convert/hls.rs serves the segments with the same window, target and pacer as the iPhone's hls_out. A pacer that waits stops reading the pipe.
    • The shared settings (SEGMENT, TARGET, WINDOW, AVC_FALLBACK, bandwidth) moved into mvp_core::convert::hls, with their tests.
    • HlsError and Ended moved to convert::hls, so both platforms share airplay/conversion.rs.
  • The Mac's converted load (airplay/macos.rs in the app):
    • It probes the source with the sidecar after mpv has stopped, so the probe isn't a second connection. While mpv plays, only its codecs are known, not every audio track.
    • It plans the conversion: H.264 copied, interlaced H.264 or other video on VideoToolbox, HDR refused, and audio by pick_audio with AirPlay's AAC and MP3.
    • It serves the stream to a private subnet on Wi-Fi, Ethernet or a bridge (wifi::lan_peers), the default route's own when it's one of them, so a VPN on the default route doesn't count. The default route's subnet is the fallback. The rules are the same as for the iPhone's Wi-Fi.
  • The LAN server answers a missing name under its token with a 404, so a TV that asks for a dropped segment moves on. A path outside the token still gets nothing.
  • Stopping a conversion waits, up to 5 s, for ffmpeg to hang up on the provider, so Play here isn't a second connection. The conversion also ends when the item fails or no TV takes it.
  • airplay_converter_support gives the sidecar's real support on macOS.
  • install for the conversions moved into airplay.rs, shared by iOS and macOS.

Docs: a CHANGELOG line, the privacy policy's AirPlay bullet now names the Mac, and architecture.md covers the segmenter and HLS output.

How it was tested

  • macOS
  • Linux (Wayland)
  • Linux (X11)

On a Mac, the reviewing session built and tested the Mac-only code at ba816a3:

  • cargo test --workspace passes: app 566, mvp-core 883, plugin 190.
  • Clippy with -D warnings on Rust 1.99 is clean.
  • Vitest passes 3124 tests, and tsc, lint and format are clean.
  • Every selector left in airplay/macos.rs exists in the macOS SDK.
  • Putting back each review fix's hunk on its own, the intended test caught all 24 reverts.

Here, I type-check the macOS code with cargo clippy --workspace --target aarch64-apple-darwin --all-targets -- -D warnings, using a stub C compiler, since nothing links. It's clean.

On Linux:

  • cargo clippy --workspace --all-targets -- -D warnings is clean.
  • cargo test --workspace passes: hls-remux 23, app 662, mvp-core 886, plugin 208. Under a load average near 19, one remote::link test, which this doesn't touch, timed out twice; it passes alone.
  • pnpm lint, pnpm format:check and tsc pass, Vitest passes 3124 tests (a full run under a load average near 19 timed out in tests this doesn't touch, such as Settings, which pass alone), and pnpm build:desktop builds.

New tests:

  • convert::ts (31): cuts at keyframes, the shortest and longest lengths, the PAT/PMT copies and their counters, audio-only streams, a 33-bit wrap, B-frames, the byte cap, bytes split anywhere, the SPS profile, HEVC, two wraps and the wrap boundary, a PAT split over two packets, and the packet fields.
  • convert::hls in the app (23), with the real sidecar on loopback:
    • an H.264 and MP2 movie served as HLS, read back by ffmpeg with -xerror
    • an unplayed movie holding ffmpeg at the lead until it plays
    • refused and missing sources
    • a short movie, a timeout, and a drop that ends ffmpeg
    • the publisher's master, window and start rules
    • the pacer, paused, playing and live
    • the plan from a probe
  • wifi::lan_peers: a VPN on the default route with a /32 or a public address, the default route's own local network first, a bridge, a subnet too wide, and the fallback.
  • The plugin: the not-picked state and its event, which clears the route; a no-pick or a report for an earlier load is dropped; a Mac's item stays opening at its start until the video is on a receiver; Pick's mute, pause and limit rules; the commands on macOS.
  • Conversions: a stop waits for a starting load and for the source to close, and gives up at the limit.
  • The LAN server: a missing or dropped name under the token is a 404, in the server's, Cast's, hls_out's and the converter's tests.
  • The playlist: EXT-X-DISCONTINUITY, EXT-X-DISCONTINUITY-SEQUENCE as tagged segments leave, and a segment past the target listed as the target.
  • Vitest: the Mac's warning and sender; Play here on a Mac; AirPlay on for iOS and macOS only; a no-pick shown as opening with no error, ending the conversion and playing here again from where it was sent; a failure ending the conversion; play and pause doing nothing while the TV is lost.

Mutation testing, run locally on the changed lines:

  • cargo-mutants: everything was caught or unviable, except the skips listed here.
    • mvp-core: 31 segmenter mutants were missed at first.
      • Most were | joining bits that don't overlap. Those fields are now built with u16::from_be_bytes, or by adding, so there's no equivalent mutant left.
      • has_media never returned false, because a segment always holds the packet that started it, so I removed it.
      • The rest have new tests.
    • read_pmt: with 5 * es_info, the loop never moves on, so it ends as a timeout, which counts as caught. The real code always moves at least 5 bytes.
    • The second review's fixes, on the lines changed since 469c822: mvp-core 61 caught; the plugin 21 caught and 3 missed in reading.rs, which were equivalent (a shift by 0 and an or of bits that don't overlap) and are gone now that the flags are written as numbers. A rerun of reading.rs caught all 16. Stryker found no mutants on the frontend lines, which are awaits and an assignment.
    • The review fixes, on the lines changed since 458e6de: mvp-core 98 caught and 1 missed, now killed by a new test; the plugin 26 caught; the app 11 caught. The app's one miss is airplay_end_conversion, whose skip comment CI reads.
  • .cargo/mutants.toml: I added both AirPlay macos.rs files and wifi::default_route_address. Linux never builds them, so their mutants pass without being built. The iOS files are already there for the same reason, and the changed-code job will warn about these additions.
  • New skip comment: install_conversions gets an FnValue skip, since it only builds for iOS and macOS. The skips on airplay_end_conversion and install were there before, and now say macOS too.
  • Stryker: the changed frontend lines score 100%, 53 of 53 for the review fixes.
  • One new Stryker disable comment, for ConditionalExpression on the channel check before playing here again: a send needs a channel, and nothing clears it while AirPlay plays. It's the same as the one on Play here.

Device checks on the Mac, for the user, with the Samsung TV:

  1. The AirPlay button shows when the TV is on, and the picker opens from it, anchored at the button.
  2. Pick the TV: direct MP4 VOD with resume, and Xtream live .m3u8. The video plays on the TV with sound, and the Mac stays silent.
  3. Close the picker without picking, or pick this Mac. Within about 10 s, the item plays here again from where it was, with no message. The Mac stays silent until then.
  4. After the picker closes, click the page where it opened, such as the AirPlay button itself: the page gets the click. Nothing here can click a real window, so only this check can see it.
  5. Time how long the TV takes to show the video after the pick, from standby too. The limit is 10 s.
  6. Converted: an M3U raw TS live channel, a DVB channel with MP2, AC-3 VOD, and MKV H.264 VOD. Check the sound on the TV, and that seeking a converted movie converts again from there.
  7. HEVC or interlaced H.264 VOD transcoded with VideoToolbox.
  8. Pause a converted movie for 2 minutes, then play: it carries on, and memory stays flat.
  9. Turn the TV off mid-play: the player says the connection was lost.
    • Before Play here, press space and the panel's play: the Mac stays silent.
    • Then Play here plays on the Mac.
  10. With a VPN on, a converted stream still reaches the TV.
  11. Who fetches a direct stream: watch nettop -p Cinette and the provider's connection count. If the Mac relays the stream, desktopSender needs a flag. Nothing documents this.
  12. Whether the 1x1 hidden AVPlayerLayer is needed for video, as on iOS.
  13. Whether the firewall asks when the converter's server first listens.
  14. The lid closed while converting: the Mac stays awake, as for a cast.

After review

Each fix has a test that fails without it, except where it says it can't.

  1. The picker took clicks after it closed. It's hidden in routePickerViewDidEndPresentingRoutes: and when it has no button, and shown just before the click that opens it. Only device check 4 can see it.
  2. The Mac could play the sound. AVPlayer is muted whenever the video isn't on a receiver, and when an item stops. Play and pause do nothing while the TV is lost, in the destination and in airplayKey. Tested in pick_tests.rs and Vitest.
  3. ffmpeg ran on after a no-pick or a failure. The destination ends the conversion on any error, a no-pick included.
  4. A no-pick was an error and moved the resume point. The player plays the item here again from where it was sent, with no message. Until the video is on a receiver, the Mac's item shows as opening at its start, so no resume point moves on. The limit is 10 s, and pick.rs says why. Vitest covers the path through airplay.ts, useAirPlay and the player.
  5. NotPicked kept the route. A no-pick emits a null route, and the Mac's ROUTE clears.
  6. usesExternalPlaybackWhileExternalScreenIsActive and prioritizesVideoDevices are gone on macOS. The review checked on a Mac that every selector left exists in the macOS SDK.
  7. A VPN on the default route. wifi::lan_peers prefers a private address on en* or bridge*. The server is IPv4 only, so fc00::/7 doesn't come into it.
  8. A stop didn't wait for ffmpeg.
    • Conversions::stop waits, up to CLOSE_LIMIT (5 s), for a starting load and every dropped conversion, and airplay_end_conversion awaits it. feat(cast): cast to Google Cast receivers from the player #336, now in dev, does the same for Cast.
    • In airplay.ts, a conversion that the TV's state ended (lost, an error or no pick) keeps its promise. The destination's stop() and its next load wait for it, so Play here and the restart after a no-pick never meet ffmpeg's connection. Tests have endConversion resolve late.
  9. Cuts only came at a video PES. The byte cap and the length cap apply at any audio or video PES, after a backward step too. They aren't checked at every packet: a cut in the middle of a PES splits a frame between segments.
  10. A PTS jump went into one EXTINF. A jump of more than 2 s either way cuts and marks a discontinuity, and the playlist caps EXTINF at the target. The doc on TARGET says so now.
  11. A B-frame across the wrap. unwrap takes the nearest of the three wraps, and only moves forward.
  12. No 404. A missing name under the token gets an empty 404.
  13. A report from the old item after a quick reload. On a Mac, reports and no-picks carry their load, and old ones are dropped. iOS's come from Swift, which doesn't pass the load yet, so that's a follow-up.
  14. A PMT over several packets. It's commented: ffmpeg writes it in one.
  15. Conventions:
    • The host is borrowed with try_borrow_mut. Work that finds it in use waits 50 ms and tries again, and a reading is dropped.
    • The NSView address still goes through a usize, as in the renderer.
    • The polling stays at 4 Hz, only while something is watched. KVO through objc2 would need an observer class and more unsafe for no gain the user sees.
  16. Clocks seconds apart. Each audio and video PID keeps its own clock in the segmenter: wraps, latest time, step and segment start. A jump counts only on the PID it happened on, and the other PIDs' jumps right after it are part of the same one. A segment's length is how far each clock moved in it.
    • Tests: audio 3 s behind the video for a whole stream gives plain 2 s segments, and a jump on the audio alone, the video alone or both marks one discontinuity.
    • A picture that stalls past 2 s and comes back is a jump on the video's clock, so it's marked as a discontinuity.
  17. A full segment with no length waits for the clock to move, so no EXTINF is 0.
  18. The Mac's report helpers (finite, is_numeric, the HTTP status filter, failed_report) are in airplay/reading.rs, where the tests and cargo-mutants reach them. A macOS test checks the CMTime flag bits against Core Media's.

Follow-ups:

  • Pass the load through Swift on iOS (13).
  • Read a PMT over several packets if a source ever sends one (14).
  • If the picker's menu never shows after the click, routePickerViewDidEndPresentingRoutes: never comes, and the nearly invisible picker stays over the page until the next one. Nothing tells the app whether the menu is up, so a timeout could hide a picker that's still open.

Checklist

  • The pull request targets dev
  • pnpm lint, pnpm test and cargo clippy --workspace --all-targets -- -D warnings pass
  • User-facing changes are added to the Unreleased section of CHANGELOG.md
  • No playlist URLs, logins or API keys in the code, tests or screenshots
  • I have read the Contributor License Agreement and agree to it

- The plugin's macOS backend drives AVPlayer, AVRoutePickerView and
  AVRouteDetector through the objc2 bindings, on the main thread, and
  reports to the same tracker as iOS. macOS never says which receiver a
  player's route went to, so AVPlayer starts muted, unmutes once external
  playback is active, and gives up as "not picked" after 5 s.
- mvp-core cuts ffmpeg's MPEG-TS output into HLS segments (convert::ts),
  and the desktop converter serves them with the same window and pacer as
  the iPhone's. A pacer that waits stops reading ffmpeg's pipe.
- The Mac probes the source after mpv stops, plans the conversion (H.264
  copied, other video on VideoToolbox, audio by pick_audio) and serves it
  to the subnet of its default route.
- The frontend turns AirPlay on for macOS, with the Mac's warning text and
  a Play here that only stops AirPlay, since the Mac's sound never moved.
Build the two-byte fields with from_be_bytes and the PTS with adding,
since an or of bits that don't overlap only made equivalent mutants.
Drop has_media, which a segment always passed: it holds the packet
that started it. Test a second wrap, the wrap boundary, the last
frame's length, a PAT in two packets, payloads, random access and
the PES data.
The counter's nibble joins with adding, as the other fields do, and
the adaptation-only test packet gets a length that fits the packet.
Like the iOS ones, their mutants pass every test without being built.
The default route's address is macOS only too.
The held movie now waits until ffmpeg stops at the lead, then checks
that playing lets it go on. A live channel past its lead waits for the
clock. install_conversions only builds for iOS and macOS, so a skip
comment says why no test catches it.
Copilot AI balanced review requested due to automatic review settings October 21, 2026 07:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…it's used

The plugin's no-AirPlay test leaves out macOS, and a macOS test checks
that the commands queue their work, and that a load and a stop track
the item. The app's tests expect a Mac to load, to need its
conversions set up, and to report the bundled ffmpeg's support.

convert::hls builds for iOS, macOS and the Linux tests only, so
Android's build has no unused HLS errors. Its plan is macOS only:
iOS's hls_out plans from what the FFmpeg libraries read, and never
transcodes video.
The command tests don't build for iOS, so Clippy for iOS found the
import unused.
…e video

- The picker hides when it closes, so it no longer takes the page's
  clicks where it last opened, and shows again just before it opens.
- AVPlayer is muted whenever the video isn't on a receiver, and when an
  item stops, so a play while the receiver is lost stays silent.
- A pick with no receiver also clears the route.
- Two calls macOS doesn't have are gone:
  usesExternalPlaybackWhileExternalScreenIsActive and
  prioritizesVideoDevices.
- Until the video is on a receiver, a Mac's item shows as opening at
  the position it started from, so no resume point moves on.
- The Mac's reports carry their load, and one about an earlier load is
  dropped, as is a no-pick for one.
- Work that finds the host in use, as a click on the picker can, waits
  a moment instead of panicking.
- The limit for a receiver to take the video is 10 s, in pick.rs with
  the rules, which the tests now reach.
The default route can be a VPN, whose utun address has a subnet of
only itself or a public one. lan_peers takes a private address on
Wi-Fi, Ethernet or a bridge, the default route's own when it's one of
them, and the default route's only when none fits.
Conversions::stop dropped the conversion and returned, so Play here
could open a second connection while ffmpeg still had the first. It
now waits, up to the same 5 s as a load, for a starting load to give
up and for every dropped conversion to close.
A segment the window dropped closed the connection with no answer. A
TV that asks for one after a stall now gets a 404 and moves on. A
path outside the token still gets nothing.
…umps

- A segment past 8 s or 32 MB is cut at the next audio or video
  packet, not only at a video one, so a picture that stalls, or one
  picture and then only sound, still makes segments. The byte cap
  holds after a backward step too.
- A jump in the clock of more than 2 s, either way, ends the segment
  where its last packet did, and the next is listed after
  EXT-X-DISCONTINUITY, with EXT-X-DISCONTINUITY-SEQUENCE once one
  leaves the window. No EXTINF is longer than the target.
- A timestamp is placed in the wrap nearest the clock, so a B-frame
  from just before a wrap no longer lands a whole wrap ahead.
- The PMT is read from its first packet, which is how ffmpeg writes it.
…nverting

- When a Mac's pick finds no receiver, the player stops AirPlay and
  plays the item here again from where it was sent, with no error.
- The conversion ends when the item fails or no TV takes it, not only
  when the TV is lost, so ffmpeg doesn't run on for nobody.
- Play and pause do nothing while the TV is lost, from the keys and
  the panel, since a Mac would play the item itself.
# Conflicts:
#	CHANGELOG.md
#	apps/player/src-tauri/src/streams/mod.rs
#	apps/player/src/components/player/VideoPlayer.tsx
#	apps/player/src/lib/airplay.test.ts
Some channels carry audio and video seconds apart, and one shared clock
read every packet of the other as a jump, so nearly every one cut and
marked a discontinuity. Each PID now has its own wraps, latest time
and segment start, a jump counts only on the PID it happened on, and
the other PIDs' jumps that follow it are part of the same one. A
segment that's full but has no length yet waits for the clock to move,
so no EXTINF is 0.
When the TV was lost, the item failed or no TV took it, the conversion
was ended without keeping the promise, so a stop or the next load went
on while ffmpeg could still hold the provider's connection. Both wait
for it now.
finite, is_numeric, the HTTP status filter and failed_report don't need
AVFoundation, so they're in airplay/reading.rs with tests. is_numeric
takes CMTime's flag bits, and a macOS test checks them against Core
Media's.
A shift by 0 and an or of bits that don't overlap only made mutants
that change nothing.
macOS's build of reading.rs only uses the valid bit and the mask.
@MaxMB15
MaxMB15 merged commit 53bcb9b into dev Oct 21, 2026
32 checks passed
@MaxMB15
MaxMB15 deleted the feat/airplay-macos branch October 21, 2026 14:12
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.

2 participants