WWDC Quick Look 💓 By SwiftGGTeam
Master Picture in Picture on tvOS

Master Picture in Picture on tvOS

观看原视频

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:57canStopPictureInPicture 会随时变化,例如用户直接从 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.canStopPictureInPicturetrue 时,当前全屏播放器可以交换或停止已有 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 的事件。


核心启发

  1. 做一个体育多画面观看模式

做什么:让用户把主比赛留在全屏,同时把另一场比赛放进 PiP,并在关键时刻交换两路视频。

为什么值得做:Session 展示了 tvOS 特有的同时播放和 swap,startPictureInPicture 能覆盖普通开始和交换两种动作。

怎么开始:先为每一路视频准备独立 AVPlayer,标准播放器优先用 AVPlayerViewController;如果需要自定义控制条,给每一路 player 建立 AVPictureInPictureController,并观察 canStopPictureInPicture 更新按钮。

  1. 给视频 app 做不打断播放的频道导览

做什么:用户观看节目时向上打开频道列表或推荐内容,PiP 控件和自定义覆盖层各走各的入口。

为什么值得做:Session 明确指出 swipe-up 手势已被 AVPlayerViewController 用于 PiP 控件,自定义覆盖层应该迁移到 customOverlayViewController

怎么开始:把原来依赖自定义 swipe-up recognizer 的覆盖层移到 AVPlayerViewController.customOverlayViewController,再验证 PiP 控件、transport bar 和覆盖层按钮的焦点顺序。

  1. 为多路直播发布独立 Now Playing 信息

做什么:全屏直播和 PiP 直播各自显示标题、频道、进度等元数据,让系统 UI 随时能切到正确的信息源。

为什么值得做:tvOS 14 支持多个 MPNowPlayingSession,PiP 使用的 player 必须绑定到对应 session。

怎么开始:为每个 AVPlayer 建立 MPNowPlayingSession(players:),把旧的 shared MPNowPlayingInfoCenter 更新迁移到 session 的 nowPlayingInfoCenter,播放项变化时继续发布 metadata。

  1. 把播放器事件处理从 view controller 里拆出来

做什么:让 PiP 生命周期、恢复 UI、跨 app 交换处理由独立 coordinator 管理。

为什么值得做:PiP 期间用户能离开播放器页面继续浏览 app,挂在 view hierarchy 上的 delegate 可能提前释放。

怎么开始:创建一个长期存在的 player coordinator,让它持有 AVPlayerViewControllerDelegate 或 PiP delegate 相关逻辑;恢复 UI 时只做必要导航和播放器安放,避免动画。


关联 Session

评论

GitHub Issues · utterances