Skip to content

Add native Picture in Picture to the Player - #76

Merged
imbenjamin merged 3 commits into
developfrom
feature/player-picture-in-picture
Aug 16, 2026
Merged

Add native Picture in Picture to the Player#76
imbenjamin merged 3 commits into
developfrom
feature/player-picture-in-picture

Conversation

@imbenjamin

Copy link
Copy Markdown
Owner

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.

  • AetherPlaybackEngine builds/rebuilds an AVPictureInPictureController around AetherEngine's nativePlayerLayer (native/AVPlayer route only — the software route's button simply doesn't appear, see Scope below).
  • canStartPictureInPictureAutomaticallyFromInline = true handles the "backgrounding starts PiP" case with no app-level scene-phase code.
  • On PiP start/stop: sets AetherEngine's pictureInPictureActive (its background-keepalive policy) and calls its 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) — shows a "Playing in Picture in Picture" placeholder over the video surface, and the transport controls hide immediately when PiP starts / fade back in when it ends.
  • The PiP button is omitted entirely (not shown disabled) when PiP isn't possible.

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/test both pass; added PlayerViewModelTests coverage for the new engine↔ViewModel PiP callbacks/passthrough.
  • Manually verified on a physical device (iPhone 17,1) over Wi-Fi: button-initiated PiP, backgrounding auto-start, play/pause/skip from the system PiP overlay, subtitles rendering inside the PiP window, and controls hiding/restoring correctly around PiP transitions.

Closes #30

🤖 Generated with Claude Code

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>
@imbenjamin imbenjamin linked an issue Aug 16, 2026 that may be closed by this pull request
imbenjamin and others added 2 commits August 16, 2026 16:42
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.
@imbenjamin
imbenjamin merged commit ce45fbe into develop Aug 16, 2026
2 checks passed
@imbenjamin
imbenjamin deleted the feature/player-picture-in-picture branch August 16, 2026 16:17
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.

[Feature] Picture in Picture (native)

1 participant