Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@ the public-API contract.

## [Unreleased]

### Added

- **The software host's PiP source now builds its own controller.**
`SoftwarePiPSource.makePictureInPictureController()` returns an `AVPictureInPictureController`
around the display layer (nil when PiP is unsupported), with a `SoftwarePiPPlaybackDelegate`
serving play, pause, skip and the time range from the engine's transport, as the AVPlayer path
gets from AVKit. Skip is clamped to the VOD duration, and the window's playback state is
invalidated when the engine's state or duration changes. A host no longer re-implements the
delegate; it still owns the controller, rebuilds it when `softwarePiPSource` changes, and sets
`pictureInPictureActive`.
- **Ending a software session closes its PiP window.** The engine stops the PiP of the controller it
made, before the host stops, and resets `pictureInPictureActive` to false. A host with its own
controller closes PiP when `softwarePiPSource` becomes nil. The software path builds a new display
layer per load, so any load ends PiP there (no hand-over as on the native path).
- **A bound `AetherPlayerView` shapes the software layer like the picture while PiP is active.** A PiP
window mirrors the layer's own geometry, so the layer takes the picture's aspect-fit rectangle
(`resizeAspect`, `resize`) or its aspect-fill rectangle clipped by the view (`resizeAspectFill`), and
returns to the full view afterwards. A `videoGravity` change during PiP re-applies it at once. A host
that mounts `softwarePiPSource.layer` in its own view sizes the layer itself.
- **macOS: the software layer fits the system PiP window.** While PiP is active the layer is laid out at the
window's size (found through public AppKit and followed on resize; the picture-shaped layout stays when no window is
found), so the layer's `videoGravity` fits the picture. The system draws an opaque bar over the top of that
window for sample-buffer sources, which no public API removes (Apple FB22411168). A picture of another
aspect during PiP (16:9 to 4:3 and back) keeps the same window: AVKit resizes it to the new picture, and the
layer follows the window instead of falling back to the view's fit.
- **`SoftwarePiPSource.isSupported`** says whether the system offers PiP at all; the controller helper returns
nil when it is false.
- **The PiP source keeps its off-main snapshot fresh without the controller helper**, so a host using
`playbackDelegate` with its own content source gets current state and duration.

### Fixed

- **A 24.000 fps source is programmed as 24 Hz, not 23.976 Hz (AE#884, thanks @akhanwav).** `FrameRateSnap` sent
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ A scannable summary; the depth for each row lives in **[docs/formats.md](docs/fo
| Surround | 5.1 / 7.1 with correct `AudioChannelLayout` |
| Audio-only | `LoadOptions.audioOnly`: lean pipeline, no video machinery, system Now-Playing on tvOS / iOS |
| Background audio | Audio keeps playing when the app backgrounds on iOS: native AVPlayer stays alive, the software path drops video and keeps decoding audio (`backgroundPlaybackEnabled` / `pictureInPictureActive`); a paused session survives quick app switches for a grace window (`backgroundTeardownGraceSeconds`, default 15 s) before the wedge-safe teardown runs. tvOS tears down immediately (wedge-safe) unless a PiP window is active, which keeps the pipeline and loopback server alive; with PiP the software path keeps decoding video too (the window needs frames). Hosts gate corrective actions on the published `isSessionReady` |
| Picture in Picture | Native path: hosts build `AVPictureInPictureController` around `currentAVPlayer`, or around the session's own `nativePlayerLayer` when they render through `bind(view:)` rather than AVKit; while `pictureInPictureActive` a native->native load hands the AVPlayerItem over in place so the window survives next-episode transitions. Software path: published `softwarePiPSource` carries the `AVSampleBufferDisplayLayer` plus transport answers on the enqueued frames' PTS axis for sample-buffer PiP. While PiP is active the software path also composites active subtitle cues (text and PGS/DVB bitmaps) into the decoded frames, so the window shows subtitles the host overlay cannot reach. On the native path, a selected bitmap track's compositions are OCR-recognized into its WebVTT rendition, so PGS subtitles render in the PiP window there as well. iOS renders sample-buffer PiP; tvOS AVKit does not evaluate sample-buffer content sources (Apple FB9751461, verified through tvOS 26) |
| Picture in Picture | Native path: hosts build `AVPictureInPictureController` around `currentAVPlayer`, or around the session's own `nativePlayerLayer` when they render through `bind(view:)` rather than AVKit; while `pictureInPictureActive` a native->native load hands the AVPlayerItem over in place so the window survives next-episode transitions. Software path: published `softwarePiPSource` carries the `AVSampleBufferDisplayLayer` plus transport answers on the enqueued frames' PTS axis for sample-buffer PiP; `makePictureInPictureController()` builds the controller with a `SoftwarePiPPlaybackDelegate` over the engine's play, pause, skip and time range. While PiP is active the software path also composites active subtitle cues (text and PGS/DVB bitmaps) into the decoded frames, so the window shows subtitles the host overlay cannot reach. While PiP is active a bound `AetherPlayerView` shapes the software layer like the picture (aspect-fit, or aspect-fill clipped by the view) so the window mirrors it without offset or crop; a host mounting `softwarePiPSource.layer` itself sizes it the same way. On macOS the layer is laid out at the size of the system PiP window (found through public AppKit, followed on resize; the picture-shaped layout is kept when no window is found), and the system draws an opaque bar over the top of that window that no public API removes (Apple FB22411168). Ending the session closes the helper controller's window, and a load on the software path ends PiP (no hand-over as on the native path). On the native path, a selected bitmap track's compositions are OCR-recognized into its WebVTT rendition, so PGS subtitles render in the PiP window there as well. iOS renders sample-buffer PiP; tvOS AVKit does not evaluate sample-buffer content sources (Apple FB9751461, verified through tvOS 26) |
| Subtitles | Text (SRT / ASS / SSA / VTT / mov_text) inline, bitmap (PGS / DVB / DVD, plus local VobSub `.idx` + `.sub` sidecars) as `CGImage`, in-band CEA-608 closed captions (field-1, from an `eia_608`/`c608` demuxable track or extracted from A53 `cc_data` embedded in the video bitstream: H.264/HEVC SEI on the native path, decoded-frame side data such as MPEG-2 on the software path, with the caption track surfacing lazily on first real caption data), DVB teletext decoded to text cues with broadcaster colour preserved and a selectable caption page (libzvbi, `LoadOptions.teletextPage` at load, `setTeletextPage(_:)` while the channel plays), external files as first-class tracks (registered, listed, selected like embedded streams), a live channel's own HLS `SUBTITLES` renditions surfaced as tracks and fetched only once one is selected, opt-in raw ASS markup + fonts; embedded-text cues harvested from the producer's own read (instant enable, no side-channel bandwidth); opt-in native WebVTT renditions (one per text track incl. load-declared external files, language-tagged) so subtitles survive PiP / AirPlay / external display (`LoadOptions.prepareNativeSubtitles`); bitmap tracks (PGS / DVB / DVD, embedded and external .sup) join as OCR-fed renditions: on-device Vision text recognition runs while the track is selected and fills a language-tagged text rendition, so bitmap subtitles survive PiP / AirPlay / external display on the native path too (lossy by design; fullscreen keeps the pixel-accurate overlay) |
| Frames | Off-playback `FrameExtractor`: `thumbnail` (scrub preview) + `snapshot` (frame-accurate) |
| Audio tap | Opt-in `installAudioTap()`: decoded playback audio as mono Float32 48 kHz PCM with source-PTS timestamps, off the render path (live transcription, ShazamKit); delivers on the loopback, remote-HLS (VOD + live), and software paths. The stream is bound to its session, so a session-preserving reload (audio / subtitle / disc-title switch, `reloadAtCurrentPosition`) finishes it and the host re-installs on stream end |
Expand Down
76 changes: 74 additions & 2 deletions Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -940,19 +940,31 @@ public final class AetherEngine: ObservableObject {
/// the keepalive policy + pause-safety read it.
public var pictureInPictureActive = false {
didSet {
#if os(macOS)
updatePictureInPictureTracking()
#endif
updatePictureFit()
nativeHost?.pictureInPictureActive = pictureInPictureActive
// SW-PiP Phase C: flip the frame compositor with the PiP state so subtitles appear in the
// window and never double-draw under the fullscreen host overlay.
softwareHost?.updateSubtitleCompositor(cues: subtitleCues + secondarySubtitleCues, enabled: pictureInPictureActive, delaySeconds: softwareSubtitleDelaySeconds)
#if os(tvOS)
// PiP window closed while backgrounded: nothing keeps the app running anymore, so the
// wedge-safe teardown is due before idle suspension (mirrors the iOS pause-while-backgrounded path).
if oldValue && !pictureInPictureActive {
if closingPictureInPictureOwesBackgroundDecision(wasActive: oldValue) {
settleBackgroundDecisionAfterPictureInPictureClosed()
}
#endif
}
}
/// True while `stopInternal` clears `pictureInPictureActive` itself.
var isResettingPictureInPictureForStop = false
/// Whether the PiP flag going false is a window closing that owes the background decision. Not when
/// `stopInternal` is the one clearing it: that is the teardown the decision would schedule, and it
/// is half done at that point.
func closingPictureInPictureOwesBackgroundDecision(wasActive: Bool) -> Bool {
wasActive && !pictureInPictureActive && !isResettingPictureInPictureForStop
}
/// #127: seconds a PAUSED session survives backgrounding (iOS) before the wedge-safe teardown runs,
/// held under a background-task assertion so a quick app switch resumes without a pipeline rebuild.
/// 0 restores the immediate teardown. Ignored on tvOS. Keep well under the ~30 s system allowance,
Expand Down Expand Up @@ -1104,7 +1116,22 @@ public final class AetherEngine: ObservableObject {
/// against it, and it is cleared with the session so the next source cannot be laid out against
/// this one's rectangle. The native and bypass paths mount an `AVPlayerLayer`, which measures its
/// own `videoRect` and carries `AVPlayerItem.presentationSize`; this stays nil there.
@Published public internal(set) var softwareDisplaySize: CGSize?
@Published public internal(set) var softwareDisplaySize: CGSize? {
didSet {
updatePictureFit()
#if os(macOS)
updatePictureInPictureAspect()
#endif
}
}
#if os(macOS)
/// macOS: the content size of the system PiP window while a software session is in it, nil
/// otherwise. The display layer is laid out to it (AE#810).
var pictureInPictureArea: CGSize? {
didSet { if oldValue != pictureInPictureArea { updatePictureFit() } }
}
var pictureInPictureAreaTracker: PictureInPictureAreaTracker?
#endif

/// #288: the native-path counterpart of `softwarePiPSource.layer`. `AVPictureInPictureController`
/// wants the layer, not the player, so a host presenting its own PiP on the native path (tvOS has
Expand Down Expand Up @@ -1722,6 +1749,8 @@ public final class AetherEngine: ObservableObject {
// Still correct for hosts that use the engine's layer directly and for the SW displayLayer.
nativeHost?.playerLayer.videoGravity = newValue
softwareHost?.displayLayer.videoGravity = newValue
// The PiP fit depends on the gravity and no layout pass follows a change.
if softwareHost != nil { boundView?.refreshHostedLayerFrame() }
}
}
var _videoGravity: AVLayerVideoGravity = .resizeAspect
Expand Down Expand Up @@ -1989,9 +2018,43 @@ public final class AetherEngine: ObservableObject {
presentCurrentLayer()
}

/// While PiP is active the bound view shapes the software layer to the picture so the window does
/// not inherit the view's aspect; otherwise it fills the view.
func updatePictureFit() {
boundView?.setPictureFit(pictureInPictureActive && softwareHost != nil ? softwareDisplaySize : nil)
#if os(macOS)
boundView?.setPictureArea(pictureInPictureActive && softwareHost != nil ? pictureInPictureArea : nil)
#endif
}

#if os(macOS)
/// The picture's size arrived or changed: let the window tracker match against its aspect.
func updatePictureInPictureAspect() {
guard pictureInPictureActive, softwareHost != nil,
let size = softwareDisplaySize, size.width > 0, size.height > 0 else { return }
pictureInPictureAreaTracker?.updateAspect(size.width / size.height)
}

/// macOS: follow the system PiP window while a software session is in it, see
/// `PictureInPictureAreaTracker`. Stopped (and the area cleared) when PiP ends.
func updatePictureInPictureTracking() {
if pictureInPictureActive && softwareHost != nil {
if pictureInPictureAreaTracker == nil {
pictureInPictureAreaTracker = PictureInPictureAreaTracker { [weak self] in self?.pictureInPictureArea = $0 }
}
let aspect = softwareDisplaySize.flatMap { $0.width > 0 && $0.height > 0 ? $0.width / $0.height : nil }
pictureInPictureAreaTracker?.start(known: softwarePiPSource?.windowsBeforeController ?? .weakObjects(),
host: boundView?.window, aspect: aspect)
} else {
pictureInPictureAreaTracker?.stop()
}
}
#endif

/// Attaches nativeHost.playerLayer or softwareHost.displayLayer to the bound view. No-op when no host.
func presentCurrentLayer() {
guard let view = boundView else { return }
defer { updatePictureFit() }
if let host = nativeHost {
view.attach(host.playerLayer)
} else if let host = softwareHost {
Expand Down Expand Up @@ -8117,6 +8180,15 @@ public final class AetherEngine: ObservableObject {
decodedVideoFormat = nil
// #314: same detach on the software path, where the outgoing renderer's decode thread is what
// can still hand a frame over while the next host comes up.
// The window shows this session's layer: close it while the host still feeds that layer, and
// drop the host-set flag so the keepalive policy does not treat a gone window as live (a host
// that drops its controller with the source never sees a did-stop).
if let pipSource = softwarePiPSource {
pipSource.endPictureInPicture()
isResettingPictureInPictureForStop = true
pictureInPictureActive = false
isResettingPictureInPictureForStop = false
}
softwareHost?.setVideoFrameTimeObserver(nil)
softwareHost?.stop()
softwarePiPSource = nil
Expand Down
Loading
Loading