Adopting Picture in Picture Playback in tvOS
At a glance
| Item |
Summary |
| Purpose |
Compares system AVPlayerViewController Picture in Picture with a custom player-layer implementation. |
| App architecture |
A menu controller selects one of two player strategies and centralizes dismissal/restoration callbacks. |
| Main patterns |
Runtime strategy selection, weak delegates, custom composition, explicit PiP lifecycle retention. |
| Project style |
Single cross-platform target with focused menu, model, controls, and custom-player files. |
Project structure
PictureInPictureSample/
├── ViewController.swift
├── PlaybackItem.swift
├── ButtonGridView.swift
├── CustomPlayerViewController.swift
├── CustomPlayerControlsView.swift
└── AppDelegate.swift
Structure observations
PlaybackItem combines video choice and player kind, letting one menu exercise both implementations.
- The custom player separates playback/PiP ownership from transient control rendering.
Overall architecture
flowchart LR
User["Button grid"] --> Root["ViewController"]
Root --> Item["PlaybackItem"]
Item --> Queue["AVQueuePlayer"]
Root --> Choice{"PlayerKind"}
Choice --> System["AVPlayerViewController"]
Choice --> Custom["CustomPlayerViewController"]
Custom --> Layer["AVPlayerLayer"]
Custom --> PiP["AVPictureInPictureController"]
Custom --> Controls["CustomPlayerControlsView"]
System -. delegate .-> Root
Custom -. delegate .-> Root
Reference code
PictureInPictureSample/ViewController.swift:32 — the root selects a system or custom player behind the same presentation flow.
func play(item: PlaybackItem) {
let player = AVQueuePlayer(playerItem: item.video.playerItem)
let playerViewController: UIViewController
switch item.playerKind {
case .avPlayerViewController:
let controller = AVPlayerViewController()
controller.player = player
controller.delegate = self
playerViewController = controller
case .customPlayerViewController:
let controller = CustomPlayerViewController()
controller.player = player
controller.delegate = self
playerViewController = controller
}
}
Interpretation
The root owns navigation policy, while each player owns its presentation technology. Both report PiP dismissal/restoration through delegate APIs, allowing common restoration logic.
Ownership and state
classDiagram
ViewController *-- AVQueuePlayer : creates per selection
ViewController --> AVPlayerViewController : presents
ViewController --> CustomPlayerViewController : presents
CustomPlayerViewController *-- AVPlayerLayer : creates from player
CustomPlayerViewController *-- AVPictureInPictureController : creates
CustomPlayerViewController *-- CustomPlayerControlsView : owns while visible
CustomPlayerViewController o-- CustomPlayerViewControllerDelegate : weak
ActiveSet o-- CustomPlayerViewController : retains during PiP
Ownership evidence
PictureInPictureSample/CustomPlayerViewController.swift:23 — the custom controller owns PiP infrastructure but delegates navigation policy.
class CustomPlayerViewController: UIViewController {
weak var delegate: CustomPlayerViewControllerDelegate?
var player: AVPlayer? {
didSet { playerLayer = AVPlayerLayer(player: player) }
}
private var playerLayer: AVPlayerLayer?
private var pictureInPictureController: AVPictureInPictureController?
private var controlsView: CustomPlayerControlsView?
}
| Owner |
Object or state |
Relationship |
Mutation authority |
ViewController |
Selection, player instance, presented controller |
Creates and presents |
Root controller |
| Custom player |
Layer, PiP controller, controls |
Creates and retains |
Custom player callbacks |
| File-private active set |
Custom players in PiP transition/active state |
Temporarily retains |
PiP delegate methods |
| Controls view |
Buttons/progress timer |
Owns UI state; weakly references player/PiP |
Controls and delegate |
Class and protocol design
| Type |
Responsibility |
Depends on |
ViewController |
Choose player style and restore full-screen UI |
Both player delegates |
PlaybackItem |
Map a grid index to player kind and video |
AVPlayerItem creation |
CustomPlayerViewController |
Compose layer, PiP controller, and controls |
AVKit/UIKit |
CustomPlayerControlsViewDelegate |
Convert control taps into player actions |
Custom player |
CustomPlayerViewControllerDelegate |
Ask host about dismissal/restoration |
Root navigation policy |
ButtonGridViewDelegate |
Supply titles and selection handling |
Root controller |
Access control
| Symbol |
Access |
Verified effect |
Likely rationale |
activeCustomPlayerViewControllers |
file-level private |
Only PiP implementation code can retain/release active custom players. |
Prevent premature deallocation without global app exposure. |
| Custom player’s layer/PiP/controls |
private |
Host cannot partially mutate the composed player. |
Preserve lifecycle coordination. |
| Player delegates |
weak |
Player and controls do not retain the presenting owner. |
Avoid presentation cycles. |
| Button/label helper types |
private extensions/nested classes |
Styling implementation stays local. |
Reduce module surface. |
| App types |
implicit internal |
No external module API. |
Sample is an app target. |
Reference code
PictureInPictureSample/CustomPlayerViewController.swift:154 — lifecycle callbacks are the sole mutation path for the private retention set.
func pictureInPictureControllerWillStartPictureInPicture(_ controller: AVPictureInPictureController) {
activeCustomPlayerViewControllers.insert(self)
}
func pictureInPictureControllerDidStopPictureInPicture(_ controller: AVPictureInPictureController) {
activeCustomPlayerViewControllers.remove(self)
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Player selection/presentation |
ViewController |
Navigation policy. |
| System-player PiP |
AVPlayerViewController with root delegate |
Framework supplies UI/lifecycle. |
| Custom PiP composition |
CustomPlayerViewController |
Owns layer/controller coupling. |
| Control rendering and focus |
CustomPlayerControlsView |
Reusable transient UI. |
| Restoration |
Root restore method |
Same behavior for both strategies. |
| Selection model |
PlaybackItem |
Pure mapping from grid to playback choice. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Strategy selection |
PictureInPictureSample/ViewController.swift:38 |
Compares system and custom player implementations through one flow. |
| Delegate |
PictureInPictureSample/CustomPlayerViewController.swift:15 |
Keeps PiP component independent of navigation policy. |
| Composition |
PictureInPictureSample/CustomPlayerViewController.swift:54 |
Builds a custom player from AVKit primitives. |
| Lifecycle retention |
PictureInPictureSample/CustomPlayerViewController.swift:13 |
Keeps delegate/controller alive while full-screen UI is dismissed. |
| Value model |
PictureInPictureSample/PlaybackItem.swift:10 |
Encodes the two selection dimensions without conditional UI state. |
Naming conventions
Custom prefixes distinguish app-owned player/control implementations from AVKit types.
- Delegate callbacks use event/intent phrasing:
DidRequest, ShouldAutomaticallyDismiss, restoreUserInterface.
PlayerKind, PlaybackItem, and Video name domain choices rather than UI widgets.
- Files match their principal type and responsibility.
Architecture takeaways
- Centralize restoration policy when multiple player implementations support the same lifecycle.
- A custom PiP player must own both the player layer and PiP controller as a coordinated unit.
- Weak delegates solve cycles, but an active PiP flow may still require deliberate temporary retention.
- Use a value model to select implementation strategies without spreading conditionals across views.
Source map
| Source file |
Relevant symbols |
PictureInPictureSample/ViewController.swift |
Strategy selection, presentation, restoration |
PictureInPictureSample/PlaybackItem.swift |
Player/video model |
PictureInPictureSample/CustomPlayerViewController.swift |
Custom layer/PiP composition and lifecycle |
PictureInPictureSample/CustomPlayerControlsView.swift |
Control delegate and focus UI |
PictureInPictureSample/ButtonGridView.swift |
Menu capability protocol |