Repository navigation
feat(airplay): AirPlay on macOS, with HLS from the desktop converter - #338
Merged
Merged
Conversation
- 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.
…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.
This was referenced Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
Step 4 of the AirPlay track: AirPlay from the Mac, and HLS output from cast's desktop converter. Branched from
devat 3f41bba, after #323 and #329, withdevmerged in again after #337.The plugin's macOS backend (
crates/tauri-plugin-mpv/src/airplay/macos.rs)AVRoutePickerViewandAVRouteDetectorthroughobjc2-av-foundation,objc2-av-kitandobjc2-core-media0.3, the same objc2 familymacos.rsuses. Everything runs on the main queue, and the host sits inMainThreadBound.Trackeras iOS, so the state, the reconnect and lost timing, and the error codes are the same.AVAudioSessionand no public current route, onlyisExternalPlaybackActive, and only once an item plays. So:airplay/pick.rs, which the Linux tests reach.notPickedflag on the AirPlay error, likeunsupported. The sharedPlaybackErrorcodes are unchanged.HLS from the desktop converter
mvp_core::convert::output_argstakes anOutput:Matroska, which is what Cast gets and is unchanged, orMpegTs.mvp_core::convert::ts::TsSegmentercuts the MPEG-TS pipe into segments:EXT-X-DISCONTINUITYbefore the next segment, andEXT-X-DISCONTINUITY-SEQUENCEonce one leaves the window. NoEXTINFis longer than the target.CODECSprofile from the first SPS-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.rsserves the segments with the same window, target and pacer as the iPhone'shls_out. A pacer that waits stops reading the pipe.SEGMENT,TARGET,WINDOW,AVC_FALLBACK,bandwidth) moved intomvp_core::convert::hls, with their tests.HlsErrorandEndedmoved toconvert::hls, so both platforms shareairplay/conversion.rs.airplay/macos.rsin the app):pick_audiowith AirPlay's AAC and MP3.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.airplay_converter_supportgives the sidecar's real support on macOS.installfor the conversions moved intoairplay.rs, shared by iOS and macOS.Docs: a CHANGELOG line, the privacy policy's AirPlay bullet now names the Mac, and
architecture.mdcovers the segmenter and HLS output.How it was tested
On a Mac, the reviewing session built and tested the Mac-only code at ba816a3:
cargo test --workspacepasses: app 566, mvp-core 883, plugin 190.-D warningson Rust 1.99 is clean.tsc, lint and format are clean.airplay/macos.rsexists in the macOS SDK.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 warningsis clean.cargo test --workspacepasses: hls-remux 23, app 662, mvp-core 886, plugin 208. Under a load average near 19, oneremote::linktest, which this doesn't touch, timed out twice; it passes alone.pnpm lint,pnpm format:checkandtscpass, 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), andpnpm build:desktopbuilds.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::hlsin the app (23), with the real sidecar on loopback:-xerrorwifi::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.Pick's mute, pause and limit rules; the commands on macOS.hls_out's and the converter's tests.EXT-X-DISCONTINUITY,EXT-X-DISCONTINUITY-SEQUENCEas tagged segments leave, and a segment past the target listed as the target.Mutation testing, run locally on the changed lines:
|joining bits that don't overlap. Those fields are now built withu16::from_be_bytes, or by adding, so there's no equivalent mutant left.has_medianever returned false, because a segment always holds the packet that started it, so I removed it.read_pmt: with5 * 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.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 ofreading.rscaught all 16. Stryker found no mutants on the frontend lines, which areawaits and an assignment.airplay_end_conversion, whose skip comment CI reads..cargo/mutants.toml: I added both AirPlaymacos.rsfiles andwifi::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.install_conversionsgets anFnValueskip, since it only builds for iOS and macOS. The skips onairplay_end_conversionandinstallwere there before, and now say macOS too.Stryker disablecomment, forConditionalExpressionon 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:
.m3u8. The video plays on the TV with sound, and the Mac stays silent.nettop -p Cinetteand the provider's connection count. If the Mac relays the stream,desktopSenderneeds a flag. Nothing documents this.AVPlayerLayeris needed for video, as on iOS.After review
Each fix has a test that fails without it, except where it says it can't.
routePickerViewDidEndPresentingRoutes:and when it has no button, and shown just before the click that opens it. Only device check 4 can see it.airplayKey. Tested inpick_tests.rsand Vitest.pick.rssays why. Vitest covers the path throughairplay.ts,useAirPlayand the player.ROUTEclears.usesExternalPlaybackWhileExternalScreenIsActiveandprioritizesVideoDevicesare gone on macOS. The review checked on a Mac that every selector left exists in the macOS SDK.wifi::lan_peersprefers a private address onen*orbridge*. The server is IPv4 only, sofc00::/7doesn't come into it.Conversions::stopwaits, up toCLOSE_LIMIT(5 s), for a starting load and every dropped conversion, andairplay_end_conversionawaits it. feat(cast): cast to Google Cast receivers from the player #336, now indev, does the same for Cast.airplay.ts, a conversion that the TV's state ended (lost, an error or no pick) keeps its promise. The destination'sstop()and its next load wait for it, so Play here and the restart after a no-pick never meet ffmpeg's connection. Tests haveendConversionresolve late.EXTINFat the target. The doc onTARGETsays so now.unwraptakes the nearest of the three wraps, and only moves forward.try_borrow_mut. Work that finds it in use waits 50 ms and tries again, and a reading is dropped.usize, as in the renderer.unsafefor no gain the user sees.EXTINFis 0.finite,is_numeric, the HTTP status filter,failed_report) are inairplay/reading.rs, where the tests and cargo-mutants reach them. A macOS test checks theCMTimeflag bits against Core Media's.Follow-ups:
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
devpnpm lint,pnpm testandcargo clippy --workspace --all-targets -- -D warningspassUnreleasedsection ofCHANGELOG.md