Add native Picture in Picture to the Player - #76
Merged
Conversation
Wires iOS PiP for AetherEngine's native (AVPlayer) route: an AVPictureInPictureController built around engine.nativePlayerLayer, rebuilt whenever the engine's session reloads. Backgrounding while in the player auto-starts PiP (canStartPictureInPictureAutomaticallyFromInline); a button in the transport controls starts it manually and is omitted entirely (not just disabled) when PiP isn't possible - i.e. the active session is on AetherEngine's software route. On PiP start/stop, sets AetherEngine's own pictureInPictureActive (its background-keepalive policy) and calls setNativeSubtitleRendering(_:) - the documented hook for handing the currently-selected subtitle track to AVKit as a native WebVTT rendition, since the app's own subtitle overlay isn't visible inside the captured PiP layer. The full-screen player stays presented while PiP is active (not dismissed), showing a "Playing in Picture in Picture" placeholder over the video surface; the transport controls hide immediately when PiP starts and fade back in when it ends. Verified on a physical device (iPhone 17,1) over Wi-Fi: manual button entry, backgrounding auto-start, play/pause and skip from the system PiP overlay, and subtitles rendering inside the PiP window. Software-path (AV1/VP9/interlaced) PiP is out of scope here - the button simply doesn't appear on that route. Closes #30 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AetherEngine defaults to NOT owning the native video path's system Now-Playing session, since it's also used by AVPlayerViewController hosts (which get Now-Playing from AVKit itself). This app renders its own transport chrome, so it's exactly the case meant to opt in - without it, the lock screen showed only the bare-minimum info iOS infers on its own from a playing AVPlayer (elapsed time/duration), with no title, subtitle, or artwork, and matters most now that PiP/ backgrounding actually keeps playback running to look at. - AetherPlaybackEngine sets engine.ownsVideoNowPlayingSession = true at init, and registers play/pause/toggle/skip/scrub against the session's own remoteCommandCenter (rebuilt on the same $currentAVPlayer signal PiP already rebuilds on) - required once the session is owned, since that also hands command handling to the host. Skip intervals (15s back/30s forward) match the in-app transport controls. - PlayerViewModel.start() stages title/subtitle (MediaItem.railTitle/ .railSubtitle) immediately once the item resolves, with artwork following separately via RemoteImageLoader once fetched. Fixes a crash found on-device while testing the above: the artwork MPMediaItemArtwork's request-handler closure, written directly inside the @mainactor setNowPlayingInfo, inferred @mainactor isolation from its enclosing context even though its own declared type never asked for that. MediaPlayer calls the handler back later, off-main, to actually render the JPEG for Control Center - Swift's runtime isolation check trapped the instant that happened. Fixed by defining it inside a nonisolated helper instead, confirmed via a symbolicated on-device crash report (systemCrashLogs via devicectl) pointing straight at the closure. Verified on a physical device (iPhone 17,1) over Wi-Fi: playback no longer crashes, and the lock screen/Control Center card now shows title, subtitle, and artwork with working transport. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PictureInPictureOverlay now sits above PlayerControlsOverlay in the ZStack (was reversed, contradicting both views' own doc comments), and the state onChange no longer forces the transport chrome back on screen while PiP is active — fixes a reachable flow where pausing from the system PiP overlay left the Close button live on top of the placeholder. - updatePictureInPictureController() now mirrors handlePictureInPictureDidStop()'s cleanup before dropping a controller that's still mid-session, so a same-host reload during an active PiP window (e.g. a stall/reconnect) can't leave isPictureInPictureActive stuck true with no way to recover. - Lock-screen/Control Center skip-forward-30 now clamps to engine.duration, matching the in-app button and the skip-backward handler right above it. - PictureInPictureOverlay's accessibility modifiers reordered so .accessibilityHidden applies to the combined element instead of being absorbed by .accessibilityElement(children: .combine) — fixes a phantom VoiceOver announcement while PiP isn't active. Confirmed working on device.
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.
Summary
Adds iOS Picture in Picture to the Player screen, initiated either by backgrounding the app while playing or via a button in the transport controls. Play/pause, skip, and subtitles all keep working in the PiP window.
AetherPlaybackEnginebuilds/rebuilds anAVPictureInPictureControlleraround AetherEngine'snativePlayerLayer(native/AVPlayer route only — the software route's button simply doesn't appear, see Scope below).canStartPictureInPictureAutomaticallyFromInline = truehandles the "backgrounding starts PiP" case with no app-level scene-phase code.pictureInPictureActive(its background-keepalive policy) and calls itssetNativeSubtitleRendering(_:)— the documented hook for handing the currently-selected subtitle track to AVKit as a native WebVTT rendition, since the app's own subtitle overlay isn't visible inside the captured PiP layer.Scope
Native path only (HEVC/H.264 direct-play — effectively all of this app's content today). Software-path (AV1/VP9/interlaced) PiP would need AetherEngine's separate
softwarePiPSource/sample-buffer delegate API and is left as follow-up.Verification
xcodebuild build/testboth pass; addedPlayerViewModelTestscoverage for the new engine↔ViewModel PiP callbacks/passthrough.Closes #30
🤖 Generated with Claude Code