前言
一般涉及到音频播放的业务都有个全局音频条的展示和控制,笔者在做到这块场景时,利用
ZFPlayer的便利性,做了二次封装,可全局显示与控制音频条,也可以由音频条跳转音频详情页面,音频条与音频详情页面的播放信息数据可同步共享。这里只提供一种实现思路,并没有做成拿来即用的公有库,感兴趣的读者可以根据需要自行适配业务。
先来看一下效果,音频条是可以全局展示的,录屏里没有体现到其他页面时显示音频条的场景,实际是可以的
要实现音频条与音频详情页的数据共享联动效果,需要定制一个播放单例实例,动态地更改播放器的controlView即可
统一定制播放管理单例
DKPlayerManager
它的一些必要属性如下,有详细的注释
// 单例
static let shared = DKPlayerManager()
// 播放管理类
private var playerManager:ZFAVPlayerManager!
// 播放器
public var player:ZFPlayerController!
// 视频控制层
private var videoControlView:ZFPlayerControlView!
// 音频控制层
private var audioControlView:DKAudioControlView!
// 记录当前父控制器
private weak var playerParent:UIViewController!
// 当前播放的数据
public var playModel:DKPlayVideoModel?
// 控制台是否可操作
public var commandCenterEnabled:Bool! = false
// 控制台的音频信息
public var commandCenterAudioInfo:[String:Any]? = [String:Any]()
// 记录封面图
public var commandCenterCoverImage:UIImage?
// 维护播放列表,它一定有值,除非播放列表为空
public var mediaList:[DKPlayVideoModel]! = [DKPlayVideoModel]()
// 当前播放速率
public var currentRate:Float! = 1.0
// 告诉播放列表刷新
public var refreshAudioList = PublishSubject<Void?>()
播放调用方法
// 播放
public func play(with model:DKPlayVideoModel? = nil, parent:UIViewController?) {
guard let model = model else {
wl_mainWindow()?.makeToast("播放参数不能为空", position: .center)
return
}
guard let _ = model.url else {
wl_mainWindow()?.makeToast("播放地址为空", position: .center)
return
}
guard let parent = parent else {
wl_mainWindow()?.makeToast("请指定播放器的承载视图", position: .center)
return
}
if model.mode != .audio && model.mode != .video {
wl_mainWindow()?.makeToast("视频格式错误", position: .center)
return
}
readAllLocalMeidaList()
var seekTime:TimeInterval = model.currentTime
// 如果当前的播放和上一个播放不是同一个,则需要先销毁
// 是否需要重新定位时间
var needSeek:Bool! = seekTime > 0
model.read()
if playModel == nil {
model.isCurrentPlaying = true
needSeek = true
seekTime = model.currentTime
}else if playModel!.videoId != model.videoId {
playModel?.isCurrentPlaying = false
playModel?.update()
stop()
model.isCurrentPlaying = true
needSeek = true
seekTime = model.currentTime
}
model.update()
playerParent = parent
playModel = model
DKAudioBar.shared.hideAudioBar()
addMedia(with: model)
// 开始根据mode,构建音频或者视频的播放控制层
if model.mode == .video {
if videoControlView == nil {
videoControlView = ZFPlayerControlView()
videoControlView.fastViewAnimated = true
videoControlView.autoHiddenTimeInterval = 5
videoControlView.autoFadeTimeInterval = 0.5
videoControlView.prepareShowLoading = true
videoControlView.prepareShowControlView = false
videoControlView.showCustomStatusBar = true
}
}else if model.mode == .audio {
if audioControlView == nil {
audioControlView = DKAudioControlView()
audioControlView.controlDelegate = self
audioControlView.frame = parent.view.bounds
}
}
if playerManager == nil {
playerManager = ZFAVPlayerManager()
playerManager.shouldAutoPlay = true
}
// 开始构建播放控制
if player == nil {
player = ZFPlayerController(playerManager: playerManager, containerView: parent.view)
player.pauseWhenAppResignActive = true
// 这里需要区分是直接播放本地的还是网络的
if let filePath = model.filePath {
let path = kAudioDocumentsDirectory + filePath
player.assetURL = URL(fileURLWithPath: path)
}else {
player.assetURL = model.url!
}
}else {
player.containerView = parent.view
}
if model.mode == .video {
player.controlView = videoControlView
}else if model.mode == .audio {
player.controlView = audioControlView
audioControlView.videoModelSubject.onNext(model)
}
audioControlView.update(with: model.duration, currentTime: model.currentTime)
// 开始播放时,需要跳转到对应的进度
if needSeek {
player.playerReadyToPlay = { [weak self] _, _ in
self?.seek(to: seekTime)
}
}
player.playerDidToEnd = { [weak self] asset in
// 自动续播到下一个
self?.playNext()
}
setupPlayerCallbacks()
}
- 传入对应的播放数据和承载播放器显示层的父视图控制器,
parent只在进入播放详情的时候需要 - 如果满足播放需要,先要获取播放器当前的播放时长,这个是为了从音频条进入详情页时的播放记录同步
- 从本地读取播放记录
- 需要判断当前的播放管理对象的播放数据是否为空,如果为空,表示是首次打开播放器,直接将必要参数保存
- 非首次打开播放器,需要判断当前播放和即将播放的数据是否是同一个音频数据,如果不是,则需要更换播放器数据源
- 根据mode 定制音频或者视频播放层
- 构建
ZFAVPlayerManager和ZFPlayerController,这个很关键,播放器信息同步靠的主要是这个,它是唯一的,只有在首次打开播放器的时候初始化,后续更换数据源也好,切换音频条和播放器详情也好,这个都是唯一不变的 - 更新播控层信息
设置播放回调
// 设置player相关回调
private func setupPlayerCallbacks() {
player.notification?.audioInterruptionCallback = { [weak self] type in
switch type {
case .began:
// 暂停播放
self?.pause()
case .ended:
// 中断结束后继续播放
self?.play()
default:
break
}
}
player.notification?.willResignActive = {[weak self] _ in
self?.willResignActive()
}
player.notification?.didBecomeActive = {[weak self] _ in
self?.didBecomActive()
}
player.playerPlayTimeChanged = {[weak self] asset, currentTime, duration in
self?.playModel?.currentTime = currentTime
}
}
给外界使用的一些方法封装
// 当前播放是否是音频
public func isAudio() -> Bool {
return playModel?.mode == .audio
}
// 播放
public func play() {
player.currentPlayerManager.play()
}
public func play(with videoModel:DKPlayVideoModel) {
if videoModel.videoId == playModel?.videoId {
DKPlayerManager.shared.play()
}else {
play(with: videoModel, parent: playerParent)
}
}
// 暂停
public func pause() {
player.currentPlayerManager.pause()
}
// 播放下一个
public func playNext() {
// 当前播放的index
var curIndex = mediaList.firstIndex { item in
return item.videoId == self.playModel?.videoId
}
if curIndex == nil {
curIndex = 0
}
if curIndex! < mediaList.count - 1 {
curIndex! += 1
}else {// 如果已经播放到最后一个了,则从头播第一个
curIndex = 0
}
let model = mediaList[curIndex!]
// 播放完毕的,数据库要删除
playModel?.remove()
// 缓存列表也要删除
mediaList.removeAll { item in
return item.videoId == self.playModel?.videoId
}
// 刷新播放列表
refreshAudioList.onNext(nil)
model.isCurrentPlaying = true
play(with: model)
}
// 播放上一个
public func playPrevious() {
player.playThePrevious()
}
// 是否正在播放
public func isPlaying() -> Bool! {
return player.currentPlayerManager.isPlaying
}
// 是否暂停
public func isPausing() -> Bool! {
return !player.currentPlayerManager.isPlaying
}
// 当前播放时长
public func currentPlayTime() -> TimeInterval {
return player.currentTime
}
// 播放总时间
public func playDuration() -> TimeInterval {
return player.totalTime
}
// seek
public func seek(to time:TimeInterval) {
player.seek(toTime: time)
}
// 切换速率
public func changeRate(to rate:Float) {
player.currentPlayerManager.rate = rate
currentRate = rate
}
// 更换播放地址, 这个是一开始是网络播放,下载完成后需要换成本地地址
public func changePlayPath() {
if let filePath = playModel?.filePath {
let path = kAudioDocumentsDirectory + filePath
let seekTime = player.currentTime
player.assetURL = URL(fileURLWithPath: path)
player.playerReadyToPlay = { [weak self] _, _ in
self?.seek(to: seekTime)
}
}
}
public func stop() {
player.stop()
player = nil
audioControlView = nil
videoControlView = nil
playerManager = nil
playerParent = nil
playModel = nil
}
deinit {
commandCenterCoverImage = nil
disabledCommandTargets()
}
上面是DKPlayerManager的内容,接下来是音频条相关的构建与显示
音频条
音频条也是个单例的view视图,这里为了统一,将音频条的播控显示层与音频详情的播控层做成了继承统一父类的形式,音频条DKAudoBar实际是音频条播控层的载体视图
DKAudioBar
var controlView:DKAudioBarControlView!
// 单例
static let shared = DKAudioBar()
// 是否是用户手动关闭的, 默认是false,如果是true,表示用户不想要播放,就不再显示
private var closeByUser:Bool! = false
// 是否在白名单内
class public func isInBlackList() -> Bool {
// 如果当前页面在黑名单内,则不显示
let vc = wl_topController()
if let vc = vc, !DK_AUDIO_BAR_BLACK_LIST.contains(vc.className) {
return false
}
return true
}
class public func show() {
if shared.closeByUser {
return
}
// 如果没有播放实体,不显示
guard let _ = DKPlayerManager.shared.player else { return }
// 如果当前页面在黑名单内,则不显示
let vc = wl_topController()
if !isInBlackList() {
shared.showAudioBar()
}else { // 如果当前视图是播放详情,则需要显示播放详情的视图
if vc!.isKind(of: DKAudioPlayerController.self) {
DKPlayerManager.shared.play(with: DKPlayerManager.shared.playModel, parent: vc)
}
}
}
// 显示
public func showAudioBar() {
// 如果audioBar本身就是显示的,不处理
updateBarFrame()
// 否则显示
// 先更新音频条的位置
controlView.snp.remakeConstraints { make in
make.edges.equalToSuperview()
}
wl_mainWindow()!.bringSubviewToFront(self)
if !isHidden {
return
}
DKPlayerManager.shared.player.containerView = self
DKPlayerManager.shared.player.controlView = controlView
controlView.videoModelSubject.onNext(DKPlayerManager.shared.playModel)
controlView.update(with: DKPlayerManager.shared.playModel!.duration, currentTime: DKPlayerManager.shared.playModel!.currentTime)
self.isHidden = false
}
public func hideAudioBar() {
self.isHidden = true
}
有些页面我们是不需要显示音频条的,比如音频播放详情页,启动页等,我们可以管理一个黑名单列表,在控制器基类的viewDidAppear方法中调用音频条显示的方法,在内部处理是否需要显示音频条等逻辑
音频条可拖动改变位置
在音频条上添加一个pan手势,处理对应的中心点坐标即可
// MARK - 私有事件
@objc private func handlePanGesture(_ gesture:UIPanGestureRecognizer) {
let translation = gesture.translation(in: window)
var newCenter = CGPoint(x: self.center.x, y: self.center.y + translation.y)
// 保证音频条不会移出屏幕
let minY = self.bounds.height / 2
var maxY = window!.bounds.height - (self.bounds.height / 2) - DK_TAB_BAR_HEIGHT
let vc = wl_topController()
// 判断当前视图是否是tabBar的一级页面,也就是tabbar显示
if let directly = vc?.isDirectlyUnderTabBarController, directly {
maxY = window!.bounds.height - (self.bounds.height / 2) - DK_TAB_BAR_HEIGHT
}else {
maxY = window!.bounds.height - (self.bounds.height / 2)
}
newCenter.y = max(minY, min(newCenter.y, maxY))
self.center = newCenter
gesture.setTranslation(.zero, in: window)
}
DKAudioControlBase
接下来是音频控制层,主要提供了暂停,播放,改变速率,快进快退,进度条等功能,这些功能点主要在基类播控层中
extension DKAudioControlBase {
func videoPlayer(_ videoPlayer: ZFPlayerController, currentTime: TimeInterval, totalTime: TimeInterval) {
DKPlayerManager.shared.updateCommandCenterProgress()
DKPlayerManager.shared.playModel?.duration = totalTime
DKPlayerManager.shared.playModel?.currentTime = currentTime
DKPlayerManager.shared.playModel?.update()
update(with: totalTime, currentTime: currentTime)
}
func videoPlayer(_ videoPlayer: ZFPlayerController, prepareToPlay assetURL: URL) {
DKPlayerManager.shared.updateCommandCenter()
}
func videoPlayerPlayFailed(_ videoPlayer: ZFPlayerController, error: Any) {
DKPlayerManager.shared.clearCommandCenter()
}
func videoPlayerPlayEnd(_ videoPlayer: ZFPlayerController) {
// 播放完成之后,自动切换到下一个播放
}
func videoPlayer(_ videoPlayer: ZFPlayerController, playStateChanged state: ZFPlayerPlaybackState) {
playPauseBtn.isSelected = state != .playStatePlaying
}
func click(with pop: Popover, popType: WLReaderPopListType, index: Int, selected: Bool) {
// 切换速率
DKPlayerManager.shared.changeRate(to: rates[index])
pop.dismiss()
rateBtn.setImage(UIImage(named: rateIcons[index]), for: .normal)
}
}
// MARK - 设置绑定事件
public func setupBindings() {
videoModelSubject
.subscribe(onNext: {[weak self] model in
guard let model = model, let self = self else { return }
if let cover = model.cover {
self.coverImageView.kf.setImage(with: URL(string: cover)) { result in
switch result {
case .success(let value):
DKPlayerManager.shared.playModel?.downloadCoverImage = value.image
case .failure(let error):
print("下载图片失败: \(error)")
}
}
}else {
self.coverImageView.image = UIImage(named: "app_icon")
DKPlayerManager.shared.playModel?.downloadCoverImage = self.coverImageView.image
}
self.titleLabel.text = model.title
self.playPauseBtn.isSelected = !DKPlayerManager.shared.isPlaying()
self.authorInfo.text = "林海 张立宪"
})
.disposed(by: rx.disposeBag)
// 关闭事件
closeBtn.rx.tap
.subscribe(onNext: {[weak self] in
self?.controlDelegate?.didClickCloseBtn?()
})
.disposed(by: rx.disposeBag)
// 暂停/起播
playPauseBtn.rx.tap
.subscribe(onNext: {[weak self] in
guard let self = self else { return }
if self.playPauseBtn.isSelected {
DKPlayerManager.shared.play()
}else {
DKPlayerManager.shared.pause()
}
})
.disposed(by: rx.disposeBag)
// 快进
nextSeek.rx.tap
.subscribe(onNext: {
var currentTime = DKPlayerManager.shared.currentPlayTime()
currentTime += 30
DKPlayerManager.shared.seek(to: currentTime)
})
.disposed(by: rx.disposeBag)
// 快退
preSeek.rx.tap
.subscribe(onNext: {
var currentTime = DKPlayerManager.shared.currentPlayTime()
currentTime -= 15
DKPlayerManager.shared.seek(to: currentTime)
})
.disposed(by: rx.disposeBag)
// 切换速率
rateBtn.rx.tap
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
let popList = DKPopListView(frame: CGRectMake(0, 0, 236, 336))
popList.delegate = self
popList.popType = .rate
let options = [
.type(.right),
.cornerRadius(10),
.animationIn(0.3),
.blackOverlayColor(UIColor.black.withAlphaComponent(0.3)),
.arrowSize(CGSize.zero)
] as [PopoverOption]
let popover = Popover(options: options, showHandler: nil, dismissHandler: nil)
popList.popView = popover
popover.show(popList, fromView: self.rateBtn)
})
.disposed(by: rx.disposeBag)
closeBtnImageSubject
.bind(to: closeBtn.rx.image(for: .normal))
.disposed(by: rx.disposeBag)
}