仿微信文章中的音频播放(锁屏界面播放信息设置)

UI展示:


WechatIMG149.jpeg

功能:1、滑动条可以滑动、点击跳转
2、倍数选择
3、前进、后退15秒
4、锁屏界面播放信息设置和操作交互
上源码---------------------------------------
AudioView: 音频播放功能及UI

class AudioView: UIView {

    enum WaitReadyToPlayState {
        case nomal
        case pause
        case play
    }
    
    enum PlayerPlayState {
        case unknow
        case readyToPlay
        case playing
        case buffering
        case failed
        case pause
        case ended
    }
    private let playingInfoCenter = MPNowPlayingInfoCenter.default()//获取锁屏中心
    private var statusObserve: NSKeyValueObservation?
    private var playbackBufferEmptyObserve: NSKeyValueObservation?
    private var sliderTimer: GCDTimer?
    private var bufferTimer: GCDTimer?
    private var waitReadyToPlayState: WaitReadyToPlayState = .nomal
    
    //播放器
    var player: AVPlayer?
    var playerItem: AVPlayerItem? {
        didSet {
            guard playerItem != oldValue else { return }
            if let oldPlayerItem = oldValue {
                NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: oldPlayerItem)
            }
            guard let playerItem = playerItem else { return }
            NotificationCenter.default.addObserver(self, selector: #selector(didPlaybackEnds), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
            statusObserve = playerItem.observe(\.status, options: [.new]) { [weak self] _, _ in
                self?.observeStatusAction()
            }
        }
    }
    //总时长
    public private(set) var totalDuration: TimeInterval = .zero {
        didSet {
            guard totalDuration != oldValue else { return }
            if totalDuration.isNaN == true {
                totalDurationLabel.text = "00:00"
            } else {
                let time = Int(totalDuration)
                let hours = time / 3600
                let minutes = (time - hours*3600) / 60
                let seconds = time % 60
                totalDurationLabel.text = hours == .zero ? String(format: "%02ld:%02ld", minutes, seconds) : String(format: "%02ld:%02ld:%02ld", hours, minutes, seconds)
            }
            setupLockScreenInfo()
        }
    }
    //播放时长
    public private(set) var currentDuration: TimeInterval = .zero {
        didSet {
            guard currentDuration != oldValue else { return }
            if currentDuration.isNaN == true {
                currentDurationLabel.text = "00:00"
            } else {
                let time = Int(currentDuration)
                let hours = time / 3600
                let minutes = (time - hours*3600) / 60
                let seconds = time % 60
                currentDurationLabel.text = hours == .zero ? String(format: "%02ld:%02ld", minutes, seconds) : String(format: "%02ld:%02ld:%02ld", hours, minutes, seconds)
            }
        }
    }
    //播放状态
    var playState: PlayerPlayState = .unknow {
        didSet {
            guard playState != oldValue else { return }
            print("playState:\(playState)")
            switch playState {
            case .unknow:
                playBtn.isSelected = false
                break
            case .readyToPlay:
                break
            case .playing:
                playBtn.isSelected = true
                break
            case .buffering:
                break
            case .failed:
                break
            case .pause:
                playBtn.isSelected = false
                break
            case .ended:
                playBtn.isSelected = false
                break
            }
        }
    }
    //倍数
    public private(set) var rate: Float = 1.0 {
        didSet {
            guard rate != oldValue else { return }
            play()
        }
    }
    //记录倍数选择的次数
    private var rateNum = 0
    
    
    
    deinit {
        //停止接受远程响应事件
        UIApplication.shared.endReceivingRemoteControlEvents()
        self.resignFirstResponder()
    }
    override var canBecomeFirstResponder: Bool {
        return true
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        rateNum = 0
        setUI()
        supportBackgroundPlay()
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    //音频名称
    private lazy var titleLab: UILabel = {
        let v = UILabel()
        v.font = UIFont.boldSystemFont(ofSize: 15.0)
        v.textAlignment = .left
        v.textColor = UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 1)
        v.text = "音频名称"
        return v
    }()
    //slider
    private lazy var sliderView: AudioSlider = {
        let v = AudioSlider()
        v.isUserInteractionEnabled = false
        v.maximumValue = 1
        v.minimumValue = 0
        //设置初始值
        v.value = 0
        //设置可连续变化
        v.isContinuous = true
        //滑轮左边颜色,如果设置了左边的图片就不会显示
        v.minimumTrackTintColor = UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1)
        //滑轮右边颜色,如果设置了右边的图片就不会显示
        v.maximumTrackTintColor = UIColor(red: 0.89, green: 0.89, blue: 0.89, alpha: 1)
        //单纯的滑动可以使用此方法,如果添加点击跳转则需要重写touch方法
        //v.addTarget(self, action: #selector(sliderValueChanged(slider:event:)), for: .valueChanged)
        v.delegate = self
        return v
    }()
    //播放时间
    private lazy var currentDurationLabel: UILabel = {
        let v = UILabel()
        v.textColor = UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1)
        v.font = UIFont.systemFont(ofSize: 10.0)
        v.textAlignment = .left
        v.text = "00:00"
        return v
    }()
    //总时间
    private lazy var totalDurationLabel: UILabel = {
        let v = UILabel()
        v.textColor = UIColor(red: 0.6, green: 0.6, blue: 0.6, alpha: 1)
        v.font = UIFont.systemFont(ofSize: 10.0)
        v.textAlignment = .right
        v.text = "00:00"
        return v
    }()
    //播放/暂停按钮
    private lazy var playBtn: UIButton = {
        let v = UIButton(type: .custom)
        v.setImage(UIImage(named: "audio_play"), for: .selected)
        v.setImage(UIImage(named: "audio_pause"), for: .normal)
        v.addTarget(self, action: #selector(playBtnClick(_ :)), for: .touchUpInside)
        return v
    }()
    //后退15秒
    private lazy var backToAudioBtn: UIButton = {
        let v = UIButton(type: .custom)
        v.setImage(UIImage(named: "audio_back"), for: .normal)
        v.addTarget(self, action: #selector(toAudioBtnClick(_ :)), for: .touchUpInside)
        return v
    }()
    //倍数选择
    private lazy var rateBtn: UIButton = {
        let v = UIButton(type: .custom)
        v.setTitle("倍数", for: .normal)
        v.backgroundColor = UIColor(red: 0.89, green: 0.89, blue: 0.89, alpha: 1)
        v.setTitleColor(UIColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1), for: .normal)
        v.titleLabel?.font = UIFont.systemFont(ofSize: 14)
        v.layer.cornerRadius = 6.0
        v.addTarget(self, action: #selector(toAudioBtnClick(_ :)), for: .touchUpInside)
        return v
    }()
    //前进15秒
    private lazy var goToAudioBtn: UIButton = {
        let v = UIButton(type: .custom)
        v.setImage(UIImage(named: "audio_go"), for: .normal)
        v.addTarget(self, action: #selector(toAudioBtnClick(_ :)), for: .touchUpInside)
        return v
    }()

}

//MARK: - Data
extension AudioView {
    func setData(urlString: String) {
        if let url = URL(string: urlString) {
            playerItem = AVPlayerItem(asset: .init(url: url))
            player = AVPlayer(playerItem: playerItem)
        }
    }
}

//MARK: - UI
private extension AudioView {
    func setUI() {
        self.addSubview(titleLab)
        self.addSubview(sliderView)
        self.addSubview(currentDurationLabel)
        self.addSubview(totalDurationLabel)
        self.addSubview(playBtn)
        self.addSubview(backToAudioBtn)
        self.addSubview(rateBtn)
        self.addSubview(goToAudioBtn)
        
        titleLab.snp.makeConstraints { make in
            make.top.equalTo(10.0)
            make.left.equalTo(15.0)
            make.height.equalTo(21.0)
        }
        sliderView.snp.makeConstraints { make in
            make.top.equalTo(titleLab.snp_bottom).offset(6.0)
            make.left.equalTo(18.0)
            make.right.equalTo(playBtn.snp_left).offset(-13.0)
            make.height.equalTo(4.0)
        }
        currentDurationLabel.snp.makeConstraints { make in
            make.top.equalTo(sliderView.snp_bottom).offset(5.0)
            make.left.equalTo(titleLab.snp_left)
            make.height.equalTo(14.0)
        }
        totalDurationLabel.snp.makeConstraints { make in
            make.centerY.equalTo(currentDurationLabel.snp_centerY)
            make.height.equalTo(currentDurationLabel.snp_height)
            make.right.equalTo(sliderView.snp_right)
        }
        playBtn.snp.makeConstraints { make in
            make.top.equalTo(17.0)
            make.right.equalTo(-15.0)
            make.width.height.equalTo(36.0)
        }
        backToAudioBtn.snp.makeConstraints { make in
            make.top.equalTo(currentDurationLabel.snp_bottom).offset(15.0)
            make.right.equalTo(rateBtn.snp_left).offset(-45.0)
            make.width.height.equalTo(24.0)
        }
        rateBtn.snp.makeConstraints { make in
            make.centerY.equalTo(backToAudioBtn.snp_centerY)
            make.centerX.equalToSuperview()
            make.width.equalTo(65.0)
            make.height.equalTo(30.0)
        }
        goToAudioBtn.snp.makeConstraints { make in
            make.centerY.equalTo(backToAudioBtn.snp_centerY)
            make.width.equalTo(backToAudioBtn.snp_width)
            make.height.equalTo(backToAudioBtn.snp_height)
            make.left.equalTo(rateBtn.snp_right).offset(45.0)
        }
    }
}


//MARK: - AudioSliderDelegate
extension AudioView: AudioSliderDelegate {
    //开始移动
    func paSliderTouchesBegan(slider: AudioSlider, event: UIEvent) {
        pause()
        controlSliderBarValue(slider: slider, event: event)
    }
    //移动中
    func paSliderTouchesMoved(slider: AudioSlider, event: UIEvent) {
        if currentDuration.isNaN || totalDuration.isNaN {
            return
        }
        currentDuration = ceil(totalDuration * TimeInterval(slider.value))
        let dragedCMTime = CMTimeMake(value: Int64(currentDuration), timescale: 1)
        player?.seek(to: dragedCMTime, toleranceBefore: .zero, toleranceAfter: .zero)
        controlSliderBarValue(slider: slider, event: event)
    }
    //移动结束
    func paSliderTouchesEnded(slider: AudioSlider, event: UIEvent) {
        guard let playerItem = playerItem else { return }
        if slider.value == 1 {
            didPlaybackEnds()
        } else if playerItem.isPlaybackLikelyToKeepUp {
            play()
        } else {
            bufferingSomeSecond()
        }
        controlSliderBarValue(slider: slider, event: event)
    }
}


//MARK: - @objc
@objc private extension AudioView {
    //播放按钮点击事件
    func playBtnClick(_ button: UIButton) {
        button.isSelected = !button.isSelected
        if button.isSelected {
            print("播放")
            play()
        } else {
            print("暂停")
            pause()
        }
    }
    //前进后退15秒
    func toAudioBtnClick(_ button: UIButton) {
        if button == backToAudioBtn {
            print("后退15秒")
            currentDuration = currentDuration - 15
            if currentDuration < 0 {
                currentDuration = 0
            }
            let dragedCMTime = CMTimeMake(value: Int64(currentDuration), timescale: 1)
            player?.seek(to: dragedCMTime, toleranceBefore: .zero, toleranceAfter: .zero)
            sliderView.value = Float(currentDuration / totalDuration)
        } else if button == goToAudioBtn {
            print("前进15秒")
            currentDuration = currentDuration + 15
            if (totalDuration - currentDuration) < 0 {
                currentDuration = totalDuration - 2
            }
            let dragedCMTime = CMTimeMake(value: Int64(currentDuration), timescale: 1)
            player?.seek(to: dragedCMTime, toleranceBefore: .zero, toleranceAfter: .zero)
            sliderView.value = Float(currentDuration / totalDuration)
        } else if button == rateBtn {
            rateNum += 1
            if rateNum == 1 {
                rate = 1.5
                rateBtn.setTitle("x\(rate)", for: .normal)
            } else if rateNum == 2 {
                rate = 2.0
                rateBtn.setTitle("x\(rate)", for: .normal)
            } else {
                rateNum = 0
                rate = 1.0
                rateBtn.setTitle("倍数", for: .normal)
            }
        }
    }
}

//MARK: - 通知
@objc private extension AudioView {
    //播放结束通知
    func didPlaybackEnds() {
        currentDuration = 0
        sliderView.value = 0
        playState = .ended
        sliderTimer?.suspend()
        setupLockScreenInfo()
    }
}

//MARK: - 锁屏界面播放显示操作
extension AudioView {
    override func remoteControlReceived(with event: UIEvent?) {
        guard let et = event else {return}
        if et.type == .remoteControl {
            switch et.subtype {
            case .remoteControlTogglePlayPause:
                print ("暂停/播放")
                break
            case .remoteControlPreviousTrack:
                print ("上一首")
                break
            case .remoteControlNextTrack:
                print ("下一首")
                break
            case .remoteControlPlay:
                print ("播放")
                play()
                break
            case .remoteControlPause:
                print ("暂停")
                pause()
                break
            default:
                break
            }
        }
    }
}

//MARK: - 私有方法
private extension AudioView {
    func observeStatusAction() {
        guard let playerItem = playerItem else { return }
        if playerItem.status == .readyToPlay {
            playState = .readyToPlay
            totalDuration = TimeInterval(playerItem.duration.value) / TimeInterval(playerItem.duration.timescale)
            sliderTimer = GCDTimer(interval: 0.1) { [weak self] _ in
                self?.sliderTimerAction()
            }
            sliderTimer?.start()

            playbackBufferEmptyObserve = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, _ in
                self?.observePlaybackBufferEmptyAction()
            }

            switch waitReadyToPlayState {
            case .nomal:
                break
            case .pause:
                pause()
            case .play:
                play()
            }
        } else if playerItem.status == .failed {
            playState = .failed
        }
    }

    func observePlaybackBufferEmptyAction() {
        guard playerItem?.isPlaybackBufferEmpty ?? false else { return }
        bufferingSomeSecond()
    }
    
    
    func availableDuration() -> TimeInterval? {
        guard let timeRange = playerItem?.loadedTimeRanges.first?.timeRangeValue else { return nil }
        let startSeconds = CMTimeGetSeconds(timeRange.start)
        let durationSeconds = CMTimeGetSeconds(timeRange.duration)
        return .init(startSeconds + durationSeconds)
    }

    func bufferingSomeSecond() {
        guard playerItem?.status == .readyToPlay else { return }
        guard playState != .failed else { return }

        player?.pause()
        sliderTimer?.suspend()
        bufferTimer?.cancel()

        playState = .buffering
        bufferTimer = GCDTimer(interval: 0, delaySecs: 3.0, repeats: false, action: { [weak self] _ in
            guard let playerItem = self?.playerItem else { return }
            if playerItem.isPlaybackLikelyToKeepUp {
                self?.play()
            } else {
                self?.bufferingSomeSecond()
            }
        })
        bufferTimer?.start()
    }

    func sliderTimerAction() {
        guard let playerItem = playerItem else { return }
        guard playerItem.duration.timescale != .zero else { return }

        currentDuration = CMTimeGetSeconds(playerItem.currentTime())
        sliderView.value = Float(currentDuration / totalDuration)
    }
    
    func controlSliderBarValue(slider: AudioSlider, event: UIEvent?) {
        if let allTouches = event?.allTouches as? NSSet, let touch = allTouches.anyObject() as AnyObject? {
            let touchLocation = touch.location(in: slider)
            let value = (slider.maximumValue - slider.minimumValue) * Float(touchLocation.x / slider.frame.width)
            slider.value = value
        }
    }
}

//MARK: - 公有方法
extension AudioView {
    func play() {
        guard let playerItem = playerItem else { return }
        if playState == .failed {
            print("播放失败,请检查资源")
        }
        guard playerItem.status == .readyToPlay else {
            waitReadyToPlayState = .play
            return
        }
        guard playerItem.isPlaybackLikelyToKeepUp else {
            bufferingSomeSecond()
            return
        }
        if playState == .ended {
            player?.seek(to: CMTimeMake(value: 0, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero)
        }
        playState = .playing
        player?.play()
        player?.rate = rate
        sliderTimer?.resume()
        waitReadyToPlayState = .nomal
        
        setupLockScreenInfo()
    }

    func pause() {
        guard playerItem?.status == .readyToPlay else {
            waitReadyToPlayState = .pause
            return
        }
        playState = .pause
        player?.pause()
        sliderTimer?.suspend()
        bufferTimer?.cancel()
        waitReadyToPlayState = .nomal
    }

    func stop() {
        statusObserve?.invalidate()
        playbackBufferEmptyObserve?.invalidate()

        statusObserve = nil
        playbackBufferEmptyObserve = nil

        playerItem = nil
        player = nil


        waitReadyToPlayState = .nomal

        playState = .unknow
        sliderView.value = 0
        totalDuration = 0
        currentDuration = 0
        sliderTimer?.cancel()
    }
}

//MARK: - 后台播放,锁屏界面设置
private extension AudioView {
    //支持后台播放
    func supportBackgroundPlay() {
        let audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback)
            try audioSession.setActive(true)
        } catch let err {
            print("后台播放失败:",err.localizedDescription)
        }
        //接受远程响应事件
        UIApplication.shared.beginReceivingRemoteControlEvents()
        self.becomeFirstResponder()
    }
    //音乐锁屏信息展示
    func setupLockScreenInfo() {
        let artwork = MPMediaItemArtwork.init(boundsSize: CGSize(width: 200, height: 200)) { size in
            return UIImage(named: "logo")!
        }
        playingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: "歌曲名称",//播放标题
                              MPMediaItemPropertyArtist: "歌手名字",
                             MPMediaItemPropertyArtwork: artwork,//播放封面图
            MPNowPlayingInfoPropertyElapsedPlaybackTime: currentDuration,//已经播放时长
                    MPMediaItemPropertyPlaybackDuration: totalDuration,//总时长
                    MPNowPlayingInfoPropertyPlaybackRate: rate,//播放倍数
                       MPNowPlayingInfoPropertyMediaType: 1]//音频类型
        
    }
}

AudioSlider:自定义滑动条和滑块

protocol AudioSliderDelegate: AnyObject {
    func paSliderTouchesBegan(slider: AudioSlider, event: UIEvent)
    func paSliderTouchesMoved(slider: AudioSlider, event: UIEvent)
    func paSliderTouchesEnded(slider: AudioSlider, event: UIEvent)
}
class AudioSlider: UISlider {
    
    weak var delegate: AudioSliderDelegate?
    
    private var lastBounds: CGRect = .zero
    private let sliderBoundX: CGFloat = 30
    private let sliderBoundY: CGFloat = 40
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setThumbImage(UIImage(named: "audio_slider"), for: .normal)
    }
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    //修改滑道的高度
    override func trackRect(forBounds bounds: CGRect) -> CGRect {
        super.trackRect(forBounds: bounds)
        return .init(origin: bounds.origin, size: CGSize(width: bounds.width, height: 4))
    }
    
    //增大滑块的触摸范围
    override func thumbRect(forBounds bounds: CGRect, trackRect rect: CGRect, value: Float) -> CGRect {
        var rect = rect
        rect.origin.x = rect.minX
        rect.size.width = rect.width
        lastBounds = super.thumbRect(forBounds: bounds, trackRect: rect, value: value)
        return lastBounds
    }
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        guard view != self else { return view }
        guard point.x >= 0, point.x < bounds.width else { return view }
        guard point.y >= -15, point.y < lastBounds.height + sliderBoundY else { return view }
        return self
    }
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
        let result = super.point(inside: point, with: event)
        guard !result else { return result }
        guard point.x >= lastBounds.minX - sliderBoundX, point.x <= lastBounds.maxX + sliderBoundX else { return result }
        guard point.y >= -sliderBoundY, point.y < lastBounds.height + sliderBoundY else { return result }
        return true
    }
    
    //触摸事件
    override func touchesBegan(_ touches: Set, with event: UIEvent?) {
        if let e = event {
            delegate?.paSliderTouchesBegan(slider: self, event: e)
        }
    }
    override func touchesMoved(_ touches: Set, with event: UIEvent?) {
        if let e = event {
            delegate?.paSliderTouchesMoved(slider: self, event: e)
        }
    }
    override func touchesEnded(_ touches: Set, with event: UIEvent?) {
        if let e = event {
            delegate?.paSliderTouchesEnded(slider: self, event: e)
        }
    }
    
}

GCDTimer: 定时器

class GCDTimer: NSObject {
    typealias actionBlock = ((NSInteger) -> Void)
    /// 执行时间
    private var interval: TimeInterval!
    /// 延迟时间
    private var delaySecs: TimeInterval!
    /// 队列
    private var serialQueue: DispatchQueue!
    /// 是否重复
    private var repeats: Bool = true
    /// 响应
    private var action: actionBlock?
    /// 定时器
    private var timer: DispatchSourceTimer!
    /// 是否正在运行
    private var isRuning: Bool = false
    /// 响应次数
    private(set) var actionTimes: NSInteger = 0

    /// 创建定时器
    ///
    /// - Parameters:
    ///   - interval: 间隔时间
    ///   - delaySecs: 第一次执行延迟时间,默认为0
    ///   - queue: 定时器调用的队列,默认主队列
    ///   - repeats: 是否重复执行,默认true
    ///   - action: 响应
    init(interval: TimeInterval, delaySecs: TimeInterval = 0, queue: DispatchQueue = .main, repeats: Bool = true, action: actionBlock?) {
        super.init()
        self.interval = interval
        self.delaySecs = delaySecs
        self.repeats = repeats
        serialQueue = queue
        self.action = action
        timer = DispatchSource.makeTimerSource(queue: serialQueue)
    }

    /// 替换旧响应
    func replaceOldAction(action: actionBlock?) {
        guard let action = action else {
            return
        }
        self.action = action
    }

    /// 执行一次定时器响应
    func responseOnce() {
        actionTimes += 1
        isRuning = true
        action?(actionTimes)
        isRuning = false
    }

    deinit {
        cancel()
    }
}

extension GCDTimer {
    /// 开始定时器
    func start() {
        timer.schedule(deadline: .now() + delaySecs, repeating: interval)
        timer.setEventHandler { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.actionTimes += 1
            strongSelf.action?(strongSelf.actionTimes)
            if !strongSelf.repeats {
                strongSelf.cancel()
                strongSelf.action = nil
            }
        }
        resume()
    }

    /// 暂停
    func suspend() {
        if isRuning {
            timer.suspend()
            isRuning = false
        }
    }

    /// 恢复定时器
    func resume() {
        if !isRuning {
            timer.resume()
            isRuning = true
        }
    }

    /// 取消定时器
    func cancel() {
        if !isRuning {
            resume()
        }
        timer.cancel()
    }
}

使用:

       let v = AudioView()
        v.backgroundColor = UIColor.init(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
        v.layer.cornerRadius = 6.0
        v.setData(urlString: "xxxxxxxxxxx.mp3")
        self.view.addSubview(v)
        
        v.snp.makeConstraints { make in
            make.top.equalTo(200.0)
            make.left.equalTo(12.0)
            make.right.equalTo(-12.0)
            make.height.equalTo(113.0)
        }

demo地址:https://github.com/CoderFDS/AudioViewDemo.git

你可能感兴趣的:(仿微信文章中的音频播放(锁屏界面播放信息设置))