(四)深入理解AVFoundation-播放:高度自定义视频播放器 UI


引言

在之前的博客中,我们已经介绍了如何实现一个简单的播放器,并通过监听资源和播放器的属性来提升播放体验。因此本篇博客将带你进一步自定义播放器 UI。通过构建自己的播放控制界面(如播放/暂停按钮、进度条、全屏切换等),我们能够提供更符合需求的播放体验,并且支持用户通过手势操作进行更精细的控制。

播放画面的视图

在正式介绍“播放/暂停按钮”之前,我们先来看一下播放器的画面是如何展示在视图上的。

我们通过自定义的 PHPlayerView,使用 AVPlayerLayer 来承载 AVPlayer 的视频内容。通过覆写 layerClass 属性,让 PHPlayerView 的底层图层类型变为 AVPlayerLayer,从而直接承接播放画面。

import UIKit
import AVFoundation

class PHPlayerView: UIView {

    override class var layerClass: AnyClass {
        return AVPlayerLayer.self
    }

    /// 设置播放器
    /// - Parameter player: 播放器
    func setPlayer(_ player: AVPlayer) {
        guard let layer = self.layer as? AVPlayerLayer else { return }
        layer.player = player
    }
}

当然了可以直接创建AVPlayerLayer添加到图层之上。有了这个播放画面视图之后,我们就可以继续搭建播放控制相关的 UI,比如播放/暂停按钮。

 协议

为了让项目结构清晰,我们定义了两个关键的协议,来解决播放控制器与播放控制视图组件耦合的问题。

PHPlayerProtocol

该协议为播放器的代理协议,协议内定义了播放器相关的内容发生变化时需要指定的方法,而需要监听变化的UI组件需要遵守该协议,并实现协议方法。目前协议方法包含了播放状态的改变以及播放进度的改变。具体代码如下:

import Foundation

protocol PHPlayerProtocol:NSObjectProtocol {
    
    /// 播放状态发生变化
    /// - Parameter status: 播放状态
    func playerStatusDidChange(status: PHPlayerStatus)
    
    /// 播放进度
    /// - Parameters:
    ///  - Parameter currentTime: 当前时间
    ///  - Parameter totalTime: 总时间
    func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval)
    
}

PHControlProtocol

该协议为播放UI组件的代理协议,协议内定义了播放UI组件对播放控制器操作的方法,而播放控制器需要遵循该协议,并实现这些方法。目前协议内包含了播放、暂停、快进到指定时间三个方法。具体代码如下:

import Foundation

protocol PHControlProtocol:NSObjectProtocol {
    /// 播放
    func play()
    /// 暂停
    func pause()
    /// 指定位置播放
    /// - Parameter time: 时间
    func seekTo(time: TimeInterval)
    
}

extension PHControlProtocol {
    func play() {}
    func pause() {}
    func seekTo(time: TimeInterval) { }
}

自定义播放控制 UI

我们将除播放视图以外的部分,分成两个部分:

  1. PHPlayerInfoView:视频信息和返回按钮。
  2. PHPlayerControlView:视频的自定义播放组件UI。

而这两部分,我们选择一个专门的视图 PHPlayerOverlayView 用来承载,与播放画面的视图完全隔离。

整个结构如下图所示:

代码如下:

import UIKit

class PHPlayerOverlayView: UIView, PHPlayerProtocol {
    
    /// 视频信息
    let videoInfoView = PHPlayerInfoView()
    /// 控制视图
    let controlView = PHPlayerControlView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
        setLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        // 视频信息
        self.addSubview(videoInfoView)
        
        // 添加控制视图
        self.addSubview(controlView)
    }
    
    private func setLayout() {
        // 视频信息
        videoInfoView.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview()
            make.top.equalToSuperview()
            make.height.equalTo(MW_NAVIGATIONBAR_HEIGHT)
        }
        // 控制视图
        controlView.snp.makeConstraints { make in
            make.bottom.equalToSuperview()
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(125.0 + MW_BOTTOM_SAFE_HEIGHT)
        }
    }

....
}

我们先以主要功能为主,依次来介绍实现 “播放/暂停按钮” 、“进度条(UISlider)”、“当前时间/总时间显示”、“播放完成后的重播/状态提示”。

播放/暂停按钮

在控制播放功能时,我们通过自定义 UIButton 来实现对 AVPlayer 的播放与暂停控制。同时结合播放器的播放状态回调,动态更新按钮的图标,切换播放/暂停的视觉状态。

具体的控制 UI 我们集中封装在PHPlayerControlView中,该类负责承载播放控制相关的所有交互组件,例如播放/暂停按钮、进度条、时间标签等。

class PHPlayerControlView: UIView {
    
    ....
    /// 播放按钮
    let playButton = UIButton(type: .custom)
    ...
    /// 代理
    weak var delegate: PHControlProtocol?
    

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
        setLayout()
        setEvent()
        
    }
    
    private func setupView() {
        ...
        // 播放、暂停按钮
        self.addSubview(playButton)
        playButton.setImage(UIImage(named: "ph_player_play"), for: .normal)
        playButton.setImage(UIImage(named: "ph_player_pause"), for: .selected)
        ...
    }
    
    private func setLayout() {
        ...
        // 播放按钮
        playButton.snp.makeConstraints { make in
            make.leading.equalTo(currentTimeLabel)
            make.top.equalTo(currentTimeLabel.snp.bottom).offset(8.0)
            make.width.height.equalTo(40.0)
        }
        ...
    }
    
    private func setEvent() {
        // 播放、暂停
        playButton.addTarget(self, action: #selector(playButtonTapped), for: .touchUpInside)
        ...
        
    }
    
    /// 播放、暂停按钮点击事件
    @objc private func playButtonTapped() {
        if playButton.isSelected {
            delegate?.pause()
        } else {
            delegate?.play()
        }
    }
    
  1. 点按钮的点击事件发生时,判断当前按钮状态。
  2. 如果是播放状态则代理执行暂停操作。
  3. 如果是暂停状态则代理执行播放的方法。

而状态的同步,则是通过 playerStatusDidChange 方法进行同步,我们使 PHPlayerOverlayView 遵循了 PHPlayerProtocol 这个协议,然后在 PHPlayerControlView 定义了同名方法。

class PHPlayerOverlayView: UIView, PHPlayerProtocol {
    
    ....
    
    /// 播放状态改变
    /// - Parameter status: 播放状态
    func playerStatusDidChange(status: PHPlayerStatus) {
        // 同步控制视图
        self.controlView.playerStatusDidChange(status: status)
        
    }
    
    /// 播放进度
    /// - Parameters:
    /// - Parameter currentTime: 当前时间
    /// - Parameter totalTime: 总时间
    func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {
        // 同步控制视图
        self.controlView.playerDidProgress(currentTime: currentTime, totalTime: totalTime)

    }
    


}

该方法在 PHPlayerControlView 中的实现如下:

    /// 播放状态改变
    /// - Parameter status: 播放状态
    func playerStatusDidChange(status: PHPlayerStatus) {
        if status == .playing {
            playButton.isSelected = true
            // 如果是暂停、完成、失败
        } else if status == .paused || status == .completed || status == .failed {
            playButton.isSelected = false
        }
    }
  1. 会根据播放器的状态来修改播放按钮的状态。

进度条(UISlider)

播放器的进度条通常承担多个功能,除了用来实时显示当前播放进度之外,也支持用户拖拽以跳转到任意时间点,还可以同步展示缓冲进度。

在本篇中,我们将聚焦于最新核心的播放控制功能——进度显示与拖拽操作。

创建UISlider时,我们可以进行灵活的自定义,比如圆点的颜色,圆点的图片。当前进度的颜色,以及默认的轨道颜色等等。

    /// slider
    let slider = UISlider()

        // slider
        self.addSubview(slider)
        slider.minimumValue = 0
        slider.maximumValue = 1
        slider.setThumbImage(UIImage(named: "ph_player_slider_thumb"), for: .normal)

在进行进度同步时,我们通过实现 playerDidProgress(currentTime:totalTime:) 方法来更新 UISlider 的 minimumValue、maximumValue以及value属性。

    /// 播放进度
    /// - Parameters:
    /// - Parameter currentTime: 当前时间
    /// - Parameter totalTime: 总时间
    func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {
        guard totalTime > 0 else { return }
        ...
        // 设置slider
        slider.minimumValue = 0
        slider.maximumValue = Float(totalTime)
        slider.value = Float(currentTime)
    }
  1. 根据播放控制器传递过来的当前时间和总时间来更新UISlider的进度值。

为 UISlider 添加开始拖拽、拖拽、结束拖拽三个方法,并在三个方法内通过 delegate ,让播放控制执行对应的暂停、快进、播放操作。

    private func setEvent() {
        ...
        // 开始拖拽
        slider.addTarget(self, action: #selector(sliderTouchDown), for: .touchDown)
        // 拖拽
        slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged)
        // 结束拖拽
        slider.addTarget(self, action: #selector(sliderTouchUpInside), for: .touchUpInside)
        
    }

    /// slider开始拖拽
    @objc private func sliderTouchDown() {
        // 暂停
        delegate?.pause()
    }
    
    /// slider拖拽
    @objc private func sliderValueChanged() {
        ...
        delegate?.seekTo(time: currentTime)
    }
    
    /// slider结束拖拽
    @objc private func sliderTouchUpInside() {
        ...
        // 拖拽结束,开始播放
        delegate?.seekTo(time: currentTime)
        delegate?.play()
    }
  1. 当拖拽开始时执行暂停操作,边拖拽边播放的现象会很奇怪。
  2. 在拖拽的过程中播放器始终处于暂停状态,但会快进到指定时间点。
  3. 当拖拽结束后,在指定的时间点开始播放。

当前时间/总时间显示

这两组UI元素就比较简单了,因为他们不涉及任何交互,只是单方面的用来显示播放器的播放时间状态,而我们需要同步的其实有两个地方。

  1. 播放器正常播放的进度回调。
  2. 发生拖拽时,拖拽过程中当前时间的变化。
    /// 当前时间
    let currentTimeLabel = UILabel()
    /// 总时间
    let totalTimeLabel = UILabel()

        // 当前时间
        self.addSubview(currentTimeLabel)
        currentTimeLabel.textColor = .white
        currentTimeLabel.font = UIFont.systemFont(ofSize: 14)
        currentTimeLabel.text = "00:00"
        // 总时间
        self.addSubview(totalTimeLabel)
        totalTimeLabel.textColor = .white
        totalTimeLabel.font = UIFont.systemFont(ofSize: 14)
        totalTimeLabel.text = "00:00"

在播放进度发生变化的回调中处理时间显示:

    /// 播放进度
    /// - Parameters:
    /// - Parameter currentTime: 当前时间
    /// - Parameter totalTime: 总时间
    func playerDidProgress(currentTime: TimeInterval, totalTime: TimeInterval) {
        guard totalTime > 0 else { return }
        // 设置当前时间
        currentTimeLabel.text = currentTime.toHMSTimeString()
        // 设置总时间
        totalTimeLabel.text = totalTime.toHMSTimeString()
        ...
    }

在拖拽过程中对时间的显示处理:

    /// slider拖拽
    @objc private func sliderValueChanged() {
        // 设置当前时间
        let currentTime = TimeInterval(slider.value)
        currentTimeLabel.text = currentTime.toHMSTimeString()
        ...
    }

其中 toHMSTimeString() 方法是我们为 TimeInterval 添加的扩展方法,用来将时间转换为时分秒格式的字符串。

extension TimeInterval {
    /// 转成时:分:秒  没有时则是分:秒
    /// - Returns: 时:分:秒
    func toHMSTimeString() -> String {
        let totalSeconds = Int(self)
        let hours = totalSeconds / 3600
        let minutes = (totalSeconds % 3600) / 60
        let seconds = totalSeconds % 60
        if hours > 0 {
            return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
        } else {
            return String(format: "%02d:%02d", minutes, seconds)
        }
    }
}

播放完成后的重播/状态提示

在视频播放完成之后呢,也会调用 playerStatusDidChange(status:) 方法并且参数值为 .completed。我们可以根据业务需要,来执行对应的操作。

    //MARK:  播放器状态相关方法
    /// 播放状态改变
    /// - Parameter status: 播放状态
    func playerStatusDidChange(status: PHPlayerStatus) {
        if status == .playing {
            playButton.isSelected = true
            // 如果是暂停、完成、失败
        } else if status == .paused || status == .completed || status == .failed {
            playButton.isSelected = false
        } else if status == .completed {
            playCompleted()
        }
    }

    /// 播放完成
    private func playCompleted() {
        // 设置播放按钮为未选中状态
        playButton.isSelected = false
        // 设置当前时间为0
        currentTimeLabel.text = "00:00"
        // 设置slider为0
        slider.value = 0
        // 设置总时间为0
        delegate?.seekTo(time: 0)
    }
  1. 播放完成后首先修改按钮状态。
  2. 设置当前时间为00:00。
  3. 设置当前进度为0.0。
  4. 设置播放器回到0.0的位置。

结语

在本篇博客中,我们围绕 AVPlayer 播放控制 UI 的实现展开,介绍了如何构建播放画面的视图 PHPlayerView,以及如何在定义的 PHPlayerControlView 中实现播放/暂停按钮、进度条和时间显示等关键组件。

通过这些 UI 控件的封装,我们不仅提升了播放器的可交互性,也为后序更多功能扩展打下了基础。

下面的博客我们会进入 AVPlayer 的进阶功能,包含音轨、字幕、倍速等等。

猜你喜欢

转载自blog.csdn.net/weixin_39339407/article/details/146917196
今日推荐