​一文学会iOS画中画浮窗

news/2024/10/23 3:40:30/

 0e869f27b5d547c0d17e563cadc6c10e.gif

本文字数:11934

预计阅读时间:40分钟

背景

之前有看到有人用画中画实现时分秒的计时,顺手收藏了,一直没来及看。最近使用《每日英语听力》,突然发现它用画中画实现了听力语句的显示,顿时来了兴趣,所以来研究一下是怎么实现的?顺便也研究下画中画时分秒计时的实现——每次遇到某些平台每天固定时间开抢的时候,我都希望iPhone能够显示具体到秒的计时,这样就能知道什么时候开始点击合适,而不是每次都提前一分钟在那里不停的点点点却什么都抢不到...

实现

画中画一般是用来浮窗播放视频的,那如何让画中画播放自定义的界面而不是视频?下面分为5步具体来看下:

  1. 实现画中画功能,需要设置哪些开关,实现哪些方法;

  2. 基本的使用系统播放器时,画中画的实现;

  3. 自定义播放器时,画中画功能的实现又需要如何设置,有哪些不同;

  4. 如何通过画中画实现时分秒计时功能;

  5. 《每日英语听力》通过画中画播放英语听力语句时怎么实现的?

APP支持画中画功能

如何让APP支持画中画功能?首先需要设置App支持BackgroundModes,然后勾选BackgroundModes中的Audio, Airplay, and Picture in Picture

操作如下:

0a30f8e896c4ee7b6c504b2a2bf7dd19.jpeg2cd50b3a8a25da49b5105ef19bdd3803.jpeg

然后需要设置AVAudioSession,在AppDelegate.Swiftapplication(_:didFinishLaunchingWithOptions:)方法设置如下代码:

  1. 导入AVFoundation

  2. 设置AVAudioSession支持后台播放

// 导入AVFoundation
import AVFoundationfunc application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {// 添加设置代码do {// 设置AVAudioSession.Category.playback后,在静音模式下,或者APP进入后台,或者锁定屏幕后还可以继续播放。try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: AVAudioSession.Mode.moviePlayback)} catch {print(error)}return true}

使用系统播放器时画中画的实现

使用系统播放器AVPlayerViewController来实现播放器画中画,首先导入AVKit,获取要播放的资源,然后使用AVPlayerViewController来进行播放,代码如下:

import AVKit/// 获取播放的资源fileprivate func playerResource() -> AVQueuePlayer? {guard let videoURL = Bundle.main.url(forResource: "suancaidegang", withExtension: "mp4") else {return nil}let item = AVPlayerItem(url: videoURL)let player = AVQueuePlayer(playerItem: item)player.actionAtItemEnd = .pausereturn player}@IBAction func systemPlayerAction(_ sender: Any) {guard let player = playerResource() else {return}let avPlayerVC = AVPlayerViewController()avPlayerVC.player = playerpresent(avPlayerVC, animated: true) {player.play()}}

这里需要注意的是,一定要在真机上才可以看到画中画的效果,使用模拟器不行。运行后可以看到AVPlayerViewController直接支持了画中画的播放;点击进入画中画后,之前全屏的播放界面自动关掉;点击画中画返回播放界面后,画中画关闭,但是之前的播放界面也没有重新打开,效果如下:fb1736d717af33c6572486a41a9c7974.gif而这里很明显,画中画返回不了之前的播放界面是有问题的,所以要修改一下,加入可以设置再进入画中画时全屏的播放界面不关闭,点击画中画的返回是否可以正常呢?这里AVPlayerViewControllerDelegate的方法 playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool可以控制进入画中画时是否关闭当前界面。

// 在此方法中添加avPlayerVC.delegate = self@IBAction func systemPlayerAction(_ sender: Any) {guard let player = playerResource() else {return}let avPlayerVC = AVPlayerViewController()avPlayerVC.delegate = selfavPlayerVC.player = playerpresent(avPlayerVC, animated: true) {player.play()}}// 设置AVPlayerViewControllerDelegateextension ViewController: AVPlayerViewControllerDelegate {func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {// 返回false时,进入画中画后播放界面不关闭// 返回true时,进入画中画后播放界面自动关闭,默认为truereturn false}}

运行调试效果,可以看到,进入画中画时,播放界面没关闭,显示This video is playing in picture in picture,且没有关闭按钮;画中画返回时,播放界面可以继续接着播放。效果如下:

709d59b210f971121972ff6b11d51eae.gif

对比可以发现,没处理前点击进入画中画,原播放界面消失且关闭,点击画中画中的进入按钮不能进入到播放界面。处理之后,进入画中画,原播放界面消失但不关闭,点击画中画的进入按钮可以返回到全屏播放界面,但是在进入画中画后,原播放界面无法关闭。

eef2492d522f8c7a22bcb948c9ba9066.png

但是上面的效果也不是所期望的,通常进入画中画模式,是为了继续操作页面其他的内容,而上面的设置虽然可以让画中画返回时继续播放,但是却也阻碍了操作页面其他的内容,所以还是需要修改。期望的效果是,进入画中画界面,当前播放界面消失;并且从画中画返回时,还可以进入播放界面,下面来看下如何设置实现:

AVPlayerViewControllerDelegate中有另外一个方法playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) ,画中画点击返回时会触发这个方法,所以要做的内容是,在这个方法被触发时,重新唤起播放视频界面,代码如下:

extension ViewController: AVPlayerViewControllerDelegate {func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {// 这里修改为返回true,即进入画中画时关闭播放界面return true}func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {restore(playerVC: playerViewController, completionHandler: completionHandler)}
}fileprivate func restore(playerVC: UIViewController, completionHandler: @escaping (Bool) -> Void) {if let presentedVC = presentedViewController {// 说明当前正在播放的界面还存在// 先关闭界面,再弹出播放界面presentedVC.dismiss(animated: false) { [weak self] inself?.present(playerVC, animated: false) {completionHandler(true)}}} else {// 直接弹出播放界面present(playerVC, animated: false) {completionHandler(true)}}}

运行查看效果,可以看到进入画中画界面,当前播放界面消失;并且从画中画返回时,还可以进入播放界面,完美。演示如下:

95f18cd73a98ad8e342443034f5fdef9.gif

对比如下,没处理前点击进入画中画后原播放界面消失但不关闭,处理后进入画中画后,原播放界面消失且关闭,且点击画中画中的进入按钮,也可以返回到全屏播放界面。

自定义播放器时画中画的实现

自定义播放器相比于使用系统的AVPlayerViewController,需要在自定义播放器界面实现点击唤起画中画播放,并且实现画中画的代理方法AVPictureInPictureControllerDelegate,在画中画的代理方法中,处理画中画返回时的逻辑。需要着重注意的是,如果设置进入画中画后播放界面消失,则当前的播放界面会被释放掉,会导致播放界面上的画中画播放也会消失,所以需要特殊处理下,声明一个全局的来存储。参考Picture in Picture Across All Platforms

代码如下:

protocol CustomPlayerVCDelegate: AnyObject {func playerViewController(_ playerViewController: MWCustomPlayerVC,restoreUserInterfaceForPictureInPictureStopWithCompletionHandlercompletionHandler: @escaping (Bool) -> Void)
}private var activeCustomPlayerVCs = Set<MWCustomPlayerVC>()
class MWCustomPlayerVC: UIViewController {// MARK: - propertiesprivate var pictureInPictureVC: AVPictureInPictureController?weak var delegate: CustomPlayerVCDelegate?var autoDismissAtPip: Bool = false // 进入画中画时,是否自动关闭当前播放页面var enterPipBtn: CustomPlayerCircularButtonView?override func viewDidLoad() {super.viewDidLoad()// Do any additional setup after loading the view.view.backgroundColor = .blacksetupPictureInPictureVC()setupEnterPipBtn()}fileprivate func setupPictureInPictureVC() {guard let playerLayer = playerLayer else {return}pictureInPictureVC = AVPictureInPictureController(playerLayer: playerLayer)pictureInPictureVC?.delegate = self}fileprivate func setupEnterPipBtn() {enterPipBtn = CustomPlayerCircularButtonView(symbolName: "pip.enter", height: 50.0)enterPipBtn?.addTarget(self, action: #selector(handleEnterPipAction), for: [.primaryActionTriggered, .touchUpInside])view.addSubview(enterPipBtn!)enterPipBtn?.snp.makeConstraints { make inmake.right.equalToSuperview().inset(10.0)make.centerY.equalTo(self.view.snp.centerY)make.width.height.equalTo(50.0)}}// 点击唤起画中画界面@objcfileprivate func handleEnterPipAction() {pictureInPictureVC?.startPictureInPicture()}
}extension MWCustomPlayerVC: AVPictureInPictureControllerDelegate {func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {// 进入画中画播放的代理方法activeCustomPlayerVCs.insert(self)enterPipBtn?.isHidden = true}// 画中画开始播放后,当前播放界面是否消失func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {if autoDismissAtPip {dismiss(animated: true)}}// 画中画进入失败func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {activeCustomPlayerVCs.remove(self)enterPipBtn?.isHidden = false}// 画中画返回的代理方法func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {delegate?.playerViewController(self, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler: completionHandler)}
}

然后在外面使用的地方调用,并且处理画中画关闭的回调代理方法,如下:

@IBAction func customPlayerAction(_ sender: Any) {guard let player = playerResource() else {return}let playerVC = MWCustomPlayerVC()playerVC.modalPresentationStyle = .fullScreenplayerVC.delegate = selfplayerVC.player = playerplayerVC.autoDismissAtPip = truepresent(playerVC, animated: true) {player.play()}}extension ViewController: CustomPlayerVCDelegate {func playerViewController(_ playerViewController: MWCustomPlayerVC,restoreUserInterfaceForPictureInPictureStopWithCompletionHandlercompletionHandler: @escaping (Bool) -> Void) {restore(playerVC: playerViewController, completionHandler: completionHandler)}
}

运行后效果如下,可以看到,自定义的播放器处理后可以和系统自带播放器的画中画效果一样:

bedc5d15586962625dc46f86246cf23f.gif

在开始下一步之前,希望大家能思考一下:通过上面的画中画例子,已经知道画中画是怎么使用的了。那假如让你来实现一个画中画的计时,你会怎么实现,有哪些方法?

  • 笔者想的方法是,既然画中画是播放视频的,那是否可以把view转为视频?然后再用播放视频的方式,来播放view的内容?

  • 然后笔者查阅了网上其他资料,发现还有一种更tricky的思路,既然画中画在APP中弹出,那是不是能获取画中画的window,获取到window之后,直接在window上添加view的显示是不是就可以了?

下面就依次来验证一下这两种方法是否都可行?首先画中画的计时,就来验证方法一是否可行;《每日英语听力》语句的展示,来验证方法二是否可行。

时分秒计时画中画的实现

这里使用方法一,即把view转为视频,再用播放视频的方式来播放view的内容,来实现一个计时器。那么问题是如何把view转为视频?

查找不到直接的转换方法,但是参考UIPiPView,可以发现里面是:

  1. view转为CMSampleBuffer

    (参考https://soranoba.net/programming/uiview-to-cmsamplebuffer);

  2. 通过initWithSampleBufferDisplayLayer方法用AVSampleBufferDisplayLayer来初始化AVPictureInPictureController.ContentSource

  3. 再用AVPictureInPictureController.ContentSource来初始化AVPictureInPictureController

  4. 然后用AVSampleBufferDisplayLayer来展示CMSampleBuffer

  5. 最终把view显示在了AVPictureInPictureController上。

这里需要注意的一个问题是,上面的把view转为CMSampleBuffer,再把CMSampleBuffer显示到AVPictureInPictureController上的过程只是单个view,而如何变成一个流畅的视频播放呢?需要定义不断的刷新timer,那刷新的timer的间隔多少合适呢?肉眼看不到卡顿就合适,UIPiPView中推荐使用0.1/60秒。

这里就不再重复封装,直接使用UIPiPView,然后创建一个计时器,需要注意的是要显示的view是添加在UIPipView上。

代码如下:

import UIKit
import UIPiPView
import SnapKitclass MWFullTimerVC: MWBaseVC {// MARK: - propertiesprivate let pipView = UIPiPView()private let timeLabel = UILabel()private let dateFormatStr = "yyyy-MM-dd HH:mm:ss"private var timer: Timer?// MARK: - view life cycleoverride func viewDidLoad() {super.viewDidLoad()// Do any additional setup after loading the view.view.backgroundColor = UIColor.blacksetupPipView()setupTimeLabel()createDisplayLink()}fileprivate func setupPipView() {let width = UIScreen.main.bounds.widthpipView.frame = CGRect(x: 10.0, y: 0, width: width - 20.0, height: 50.0)view.addSubview(pipView)pipView.snp.makeConstraints { make inmake.leading.trailing.equalToSuperview().inset(10.0)make.top.equalToSuperview().inset(100.0)make.height.equalTo(50.0)}}fileprivate func setupTimeLabel() {timeLabel.font = UIFont.boldSystemFont(ofSize: 16.0)timeLabel.textColor = UIColor.whitetimeLabel.backgroundColor = UIColor.orangetimeLabel.textAlignment = .centerpipView.addSubview(timeLabel)timeLabel.snp.makeConstraints { make inmake.leading.trailing.equalToSuperview()make.top.equalToSuperview()make.height.equalToSuperview()}}func createDisplayLink() {timer = Timer(timeInterval: 0.1/60, repeats: true, block: { [weak self] _ inself?.refresh()})RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)timer?.fire()}// MARK: - init// MARK: - utilsfunc reloadTime() {let date = Date()let formatter = DateFormatter()formatter.dateFormat = dateFormatStrself.timeLabel.text = formatter.string(from: date)}// MARK: - actionfunc refresh() {reloadTime()}override func handleEnterPipAction() {super.handleEnterPipAction()if pipView.isPictureInPictureActive() {pipView.stopPictureInPicture()} else {pipView.startPictureInPicture(withRefreshInterval: 0.1/60.0)}}// MARK: - other}

运行后调试效果如下:

c8ffc64aacddee6954f8b55ba44dd71e.gif

可以看到上面的方法是可行的,而且画中画的大小是可自己定义的,同时不需要内置空白的视频文件。定义的视图什么样画中画的显示就是什么样。

《每日英语听力》画中画的实现

这里来验证获取画中画的window,获取到window后直接在window上添加view的显示,从而在画中画中显示自定义view的方式。

在画中画即将展示的代理方法pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController)中,获取到最上层window,然后添加自定义文字播放view。文字播放view设置每隔2秒播放下一句。

最终整体代码如下:

class MWPipWindowVC: UIViewController {func setupTextPlayerView(on targetView: UIView) {targetView.addSubview(textPlayView)textPlayView.text = text1textPlayView.snp.makeConstraints { make inmake.centerY.equalTo(targetView.snp.centerY)make.leading.trailing.equalToSuperview()make.height.equalTo(250.0)}}fileprivate func setupTimer() {timer = Timer(timeInterval: 2.0, repeats: true, block: { [weak self] _ inself?.handleTimerAction()})RunLoop.current.add(timer!, forMode: RunLoop.Mode.common)timer?.fire()}// MARK: - utils// MARK: - actionfunc handleTimerAction() {let dataList = [text1, text2, text3, text4, text5]count += 1let index = count % 5let str = dataList[index]textPlayView.text = str}}extension MWPipWindowVC: AVPictureInPictureControllerDelegate {func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {activeCustomPlayerVCs.insert(self)enterPipBtn?.isHidden = trueif let window = UIApplication.shared.windows.first {setupTextPlayerView(on: window)}}xxx
}

运行调试,最终效果如下:、

290fd6865b44ee5ac90c5b047f22b854.gif

可以看到上面的方法是可行的,但是需要注意的是,这里进入画中画时可以看到播放视频的界面闪了一下,而且画中画的尺寸是和视频尺寸一致的。所以随用这种方法时,需要提前准备好对应尺寸的空白视频,然后使用画中画播放空白视频,再把自定义的view添加到画中画的window上。

对比"view转为视频然后播放"和"播放空白视频然后将view展示在视频上方的window中"这两种实现,首先两者都可以实现自定义画中画展示的效果。但是笔者测试后发现:"view转视频再播放"占用的CPU要比后者高很多,因为需要不断的读取和刷新。而"播放空白视频然后将view展示在视频上方的window中"这种方法虽然占用的CPU低,但是因为依赖空白视频文件,所以安装包体积会被变大,而且如果要有多种样式的画中画效果,就需要多个空白视频文件。

所以两者要如何选择使用哪种方式呢?通常来说,如果没有多个画中画样式的需求,建议选择"播放空白视频然后将view展示在视频上方的window中";而如果对安装包大小敏感,且需要用户自定义画中画或者有不同样式的画中画需求,则可以考虑使用"view转为视频然后播放"的方法。

总结

2a5a31ee0d7ddb39aa999cde8c462195.png

参考

  • Adopting Picture in Picture in a Standard Player

  • Picture in Picture Across All Platforms

  • UIPiPView

  • CustomPictureInPicture


http://www.ppmy.cn/news/87078.html

相关文章

C#,码海拾贝(24)——线性方程组求解的复系数方程组的全选主元高斯-约当消去法之C#源代码,《C#数值计算算法编程》源代码升级改进版

using System; namespace Zhou.CSharp.Algorithm { /// <summary> /// 求解线性方程组的类 LEquations /// 原作 周长发 /// 改编 深度混淆 /// </summary> public static partial class LEquations { /// <summary> …

stm32的IIC驱动0.96OLED

IIC原理介绍&#xff1a; IIC是一个总线的结构但不支持总线协议 OLED介绍&#xff1a; 一、0.96寸OLED屏幕介绍 本文采用的是4针的0.96寸OLED显示进行讲解&#xff0c;采用的是SPI协议&#xff0c;速度会比采用I2C协议的更快&#xff0c;但这两者的显示驱动都一样&#xf…

【分布式系统】分布式锁实现之Redis

锁有资源竞争问题就有一定有锁的存在&#xff0c;存储系统MySQL中&#xff0c;有锁机制保证数据并发访问。而编程语言层面Java中有JUC并发工具包来实现&#xff0c;那么锁解决的问题是什么&#xff1f;主要是在多线程环境下&#xff0c;对共享资源的互斥。从而保证数据一致性。…

LeetCode 1080. Insufficient Nodes in Root to Leaf Paths【递归,二叉树】中等

本文属于「征服LeetCode」系列文章之一&#xff0c;这一系列正式开始于2021/08/12。由于LeetCode上部分题目有锁&#xff0c;本系列将至少持续到刷完所有无锁题之日为止&#xff1b;由于LeetCode还在不断地创建新题&#xff0c;本系列的终止日期可能是永远。在这一系列刷题文章…

GIS工具包

前言 GIS工具包&#xff0c;根据jts工具&#xff0c;结合实际使用场景提取出来的常用工具集合&#xff1b;涵盖几何格式转换(WKT,GeoJSON等)与geometry转换、gis距离计算、度距离单位换算、角度计算、buffer运算、映射截取、几何穿串等操作 gis-tools使用说明 gis-tools源码…

跑步锻炼问题

题目描述 本题为填空题&#xff0c;只需要算出结果后&#xff0c;在代码中使用输出语句将所填结果输出即可。小蓝每天都锻炼身体。 正常情况下&#xff0c;小蓝每天跑1千米。如果某天是周一或者月初(1日)&#xff0c;为了激励自己。小蓝要跑2千米。如果同时是周一或月初&#x…

深圳 CA 加入飞桨技术伙伴计划,共筑企业数字化根基

近日&#xff0c;深圳 CA&#xff08;全称&#xff1a;深圳市电子商务安全证书管理有限公司&#xff09;正式加入飞桨技术伙伴计划&#xff0c;双方将共同努力在管理数字化转型赛道场景建设与技术生态建设作出贡献&#xff0c;致力于用创新的技术为广大行业用户提供优秀的行业应…

RabbitMq--- 惰性队列

前言 消息堆积是Mq消费时常见的问题&#xff0c;这里我们展开说一下消息堆积的原因&#xff0c;以及RabbitMq 中是如何解决这个问题的。 1. 消息堆积问题 当生产者发送消息时的速度超过了消费者处理消息的速度&#xff0c;就会导致队列中的消息堆积&#xff0c;直到队列存储…