Highlight
tvOS 画中画让 Apple TV app 同时播放全屏视频和 PiP 视频,并用 AVPlayerViewController 或 AVPictureInPictureController 处理启动、交换、停止和恢复流程。
核心内容
Apple TV 上的视频 app 常见场景是浏览页里有多条内容:一场比赛正在播放,用户又想打开另一段视频。过去这类体验只能靠 app 自己做复杂的播放器切换。tvOS 的画中画(Picture in Picture,PiP)把这件事交给系统,让一个视频留在 PiP 窗口里,另一个视频进入全屏播放。
这场 session 的关键差异在“交换”。tvOS 会保留来自 iPadOS 的 PiP 体验,并新增同时播放两个视频的能力。用户能把全屏内容和 PiP 内容互换,app 要准备好播放器进入 PiP、从 PiP 恢复,以及跨 app 交换时的前后台切换。
实现路径分成两类。如果 app 使用标准 AVPlayerViewController,配置项目和音频会话后,系统会显示 PiP 按钮,并通过 delegate 通知开始、停止和恢复。自定义播放器则要使用 AVPictureInPictureController,根据 canStopPictureInPicture 决定界面上显示“开始”“交换”还是“停止”。
现有 Apple TV 视频 app 还要检查两处旧设计。第一,AVPlayerViewController 的 swipe-up 手势已经用于到达 PiP 控制区,互动覆盖层应该迁移到 customOverlayViewController。第二,PiP 期间用户仍可在 app 里导航,播放器 delegate 不能绑在会被销毁的 view hierarchy 上。
详细内容
项目能力和音频会话
(01:45)开始写播放器逻辑前,项目要在 Xcode 12 的 Signing and Capabilities 里添加 Background Modes,并勾选 Picture in Picture。接着把 app 的 AVAudioSession category 设为 playback。Session 说明这一步和 iPadOS 的 PiP 配置一致。
(02:13)官方示例只做一件事:取得共享 audio session,然后设置播放 category。
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(.playback)
} catch {
print("Setting category to AVAudioSessionCategoryPlayback failed.")
}
关键点:
AVAudioSession.sharedInstance()取得当前 app 的共享音频会话。setCategory(.playback)告诉系统这是播放型媒体 app,PiP 需要这类音频配置。do/catch保留设置失败的处理路径,避免静默失败后 PiP 行为不可预期。
标准播放器的 PiP 生命周期
(02:26)如果 app 使用 AVPlayerViewController,完成项目配置后,标准播放 UI 会自动显示 PiP affordance。app 需要关注的是 delegate 生命周期:开始 PiP、停止 PiP,以及从 PiP 恢复 UI。
(03:14)恢复 UI 是这里最容易出问题的一步。系统会给 app 一个机会,把界面恢复到能容纳播放器的状态。Session 明确建议快速恢复,并避免动画;如果恢复太慢,正在恢复的播放器会被终止。
自定义播放器的开始、交换和停止
(04:10)自定义播放 UI 要使用 AVPictureInPictureController。tvOS 上新增的关键状态是 canStopPictureInPicture:它为 false 时,界面应该显示 start PiP;它为 true 时,界面应该显示 swap 和 stop。
(04:52)用户点 start 时调用 startPictureInPicture。用户点 swap 时仍然调用 startPictureInPicture,因为从全屏播放器视角看,它也在进入 PiP。只有用户明确选择 stop 时,才对全屏播放器对应的 controller 调用 stopPictureInPicture。
(05:57)canStopPictureInPicture 会随时变化,例如用户直接从 PiP 窗口停止播放。自定义 UI 需要观察这个属性并刷新按钮。
_ = pipController.observe(\.canStopPictureInPicture) { controller, change in
// Update your UI
if controller.canStopPictureInPicture {
pipActions = [.swap, .stop]
} else {
pipActions = [.start]
}
}
关键点:
pipController.observe(\.canStopPictureInPicture)用 KVO 监听 PiP 状态变化。controller.canStopPictureInPicture为true时,当前全屏播放器可以交换或停止已有 PiP。pipActions = [.swap, .stop]表示自定义 UI 应露出 swap 和 stop。pipActions = [.start]表示当前界面只需要提供 start PiP。
多个 Now Playing 信息源
(06:26)tvOS 14 允许发布多个 Now Playing info centers。两个视频同时播放时,全屏内容和 PiP 内容都应该有各自的 Now Playing 信息。做法是把 AVPlayer 绑定到 MPNowPlayingSession。
(06:54)在 tvOS 上使用 PiP 时,传给 AVPictureInPictureController 的同一个 player 必须绑定到 MPNowPlayingSession。Session 还提醒:切到 MPNowPlayingSession 后,系统会忽略旧的 shared MPNowPlayingInfoCenter 更新,所以整个 app 都应该迁移到新的发布方式。
(07:06)自定义播放器 controller 的结构如下:
final class CustomPlayerViewController: UIViewController {
init(player: AVPlayer) {
let playerLayer = AVPlayerLayer(player: player)
pictureInPictureController = AVPictureInPictureController(playerLayer: playerLayer)
nowPlayingSession = MPNowPlayingSession(players: [player])
}
private func publishNowPlayingMetadata() {
nowPlayingSession.nowPlayingInfoCenter.nowPlayingInfo = // Your Now Playing info
nowPlayingSession.becomeActiveIfPossible()
}
}
关键点:
init(player: AVPlayer)把同一个 player 作为播放器 controller 的输入。AVPlayerLayer(player: player)创建画面输出层,供 PiP controller 使用。AVPictureInPictureController(playerLayer: playerLayer)管理这个播放器的 PiP。MPNowPlayingSession(players: [player])把同一个 player 绑定到独立 Now Playing session。nowPlayingInfoCenter.nowPlayingInfo写入当前视频的元数据。becomeActiveIfPossible()请求让该 session 开始发布 Now Playing 信息。
交换时的界面和架构要求
(08:23)同一个 app 内交换时,进入 PiP 的播放器会先收到 will-start,正在离开 PiP 的播放器会要求 app 恢复 UI,然后再触发 will-stop,动画完成后两个播放器收到 did-start 和 did-stop。
(09:19)跨 app 交换有两个方向。当前 app 的全屏内容进入 PiP 时,app 会在动画后进入后台。当前 app 的 PiP 内容回到全屏时,app 会先被带到前台,然后快速恢复 UI,再收到 PiP will-stop 和 did-stop。
(12:15)现有 AVPlayerViewController app 还要处理互动覆盖层。tvOS 13 起,swipe-up 手势由标准播放器保留,用来让用户到达 PiP 控件。自定义的频道列表、推荐内容或其他 slide-up 控件应该放进 customOverlayViewController。
(14:41)delegate 架构也要调整。PiP 期间用户能继续在 app 内导航,原来的 presenting view controller 可能已经不在界面层级里。Session 建议把 delegate 做成独立对象,让它在视频处于 PiP 时继续接收 AVKit 的事件。
核心启发
- 做一个体育多画面观看模式
做什么:让用户把主比赛留在全屏,同时把另一场比赛放进 PiP,并在关键时刻交换两路视频。
为什么值得做:Session 展示了 tvOS 特有的同时播放和 swap,startPictureInPicture 能覆盖普通开始和交换两种动作。
怎么开始:先为每一路视频准备独立 AVPlayer,标准播放器优先用 AVPlayerViewController;如果需要自定义控制条,给每一路 player 建立 AVPictureInPictureController,并观察 canStopPictureInPicture 更新按钮。
- 给视频 app 做不打断播放的频道导览
做什么:用户观看节目时向上打开频道列表或推荐内容,PiP 控件和自定义覆盖层各走各的入口。
为什么值得做:Session 明确指出 swipe-up 手势已被 AVPlayerViewController 用于 PiP 控件,自定义覆盖层应该迁移到 customOverlayViewController。
怎么开始:把原来依赖自定义 swipe-up recognizer 的覆盖层移到 AVPlayerViewController.customOverlayViewController,再验证 PiP 控件、transport bar 和覆盖层按钮的焦点顺序。
- 为多路直播发布独立 Now Playing 信息
做什么:全屏直播和 PiP 直播各自显示标题、频道、进度等元数据,让系统 UI 随时能切到正确的信息源。
为什么值得做:tvOS 14 支持多个 MPNowPlayingSession,PiP 使用的 player 必须绑定到对应 session。
怎么开始:为每个 AVPlayer 建立 MPNowPlayingSession(players:),把旧的 shared MPNowPlayingInfoCenter 更新迁移到 session 的 nowPlayingInfoCenter,播放项变化时继续发布 metadata。
- 把播放器事件处理从 view controller 里拆出来
做什么:让 PiP 生命周期、恢复 UI、跨 app 交换处理由独立 coordinator 管理。
为什么值得做:PiP 期间用户能离开播放器页面继续浏览 app,挂在 view hierarchy 上的 delegate 可能提前释放。
怎么开始:创建一个长期存在的 player coordinator,让它持有 AVPlayerViewControllerDelegate 或 PiP delegate 相关逻辑;恢复 UI 时只做必要导航和播放器安放,避免动画。
关联 Session
- Build SwiftUI apps for tvOS — 讲 tvOS 的按钮样式、焦点 API 和横向 shelf,适合和 Apple TV 视频首页一起设计。
- Discover search suggestions for Apple TV — 讲 Apple TV 搜索界面和
UISearchController,可补齐视频 app 的发现入口。 - Support multiple users in your tvOS app — 讲 Apple TV 多用户资料和切换,适合与媒体播放状态和用户资料一起规划。
- Discover how to download and play HLS offline — 讲 AVFoundation 下载和离线播放 HLS 内容,补足视频播放链路。
- Edit and play back HDR video with AVFoundation — 讲 AVFoundation HDR 播放和编辑,适合继续深入视频播放能力。
评论
GitHub Issues · utterances