iOS-基于ZFPlayer封装的一款音频播放器

2,446 阅读7分钟

前言

一般涉及到音频播放的业务都有个全局音频条的展示和控制,笔者在做到这块场景时,利用ZFPlayer的便利性,做了二次封装,可全局显示与控制音频条,也可以由音频条跳转音频详情页面,音频条与音频详情页面的播放信息数据可同步共享。这里只提供一种实现思路,并没有做成拿来即用的公有库,感兴趣的读者可以根据需要自行适配业务。

录屏2024-08-06 09.28.14.gif

先来看一下效果,音频条是可以全局展示的,录屏里没有体现到其他页面时显示音频条的场景,实际是可以的

要实现音频条与音频详情页的数据共享联动效果,需要定制一个播放单例实例,动态地更改播放器的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 定制音频或者视频播放层
  • 构建ZFAVPlayerManagerZFPlayerController,这个很关键,播放器信息同步靠的主要是这个,它是唯一的,只有在首次打开播放器的时候初始化,后续更换数据源也好,切换音频条和播放器详情也好,这个都是唯一不变的
  • 更新播控层信息

设置播放回调

// 设置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)

        

    }