RxMusicPlayer 2.3.3

RxMusicPlayer 2.3.3

Yoheimuta 维护。



 
依赖项
RxSwift>= 0
RxCocoa>= 0
RxRelay>= 0
 

  • 作者
  • yohei yoshimuta

RxMusicPlayer

Build Status Carthage compatible Version License

RxMusicPlayer 是RxSwift支持的avplayer的封装,使音频播放变得容易。

特点

  • 遵循 受用户控制的播放和录音应用的音频指南
  • 支持远程和本地音频文件的流式播放。
  • 提供 playpausestopplay nextplay previousskip forward/backwardprefetch metadatarepeat mode(repeat, repeat all)shuffle modedesired playback rateseek to a certain secondappend/insert/remove an item into the playlist 功能。
  • 加载元数据,包括 titlealbumartistartworkdurationlyrics
  • 与 MPNowPlayingInfoCenter 的背景模式集成。
  • 与 MPRemoteCommandCenter 的远程命令控制集成。
  • 通过 AVAudioSession.interruptionNotification 处理中断。
  • 通过 AVAudioSession.routeChangeNotification 处理路由更改。
  • 包括一个完全可行的示例项目,一个基于 UIKit 构建,另一个基于 SwiftUI 构建。

运行要求

  • iOS 10.0 或更高版本

安装

Swift Package Manager

从2.0.1版本以上。

Carthage

github "yoheimuta/RxMusicPlayer"

CocoaPods

pod "RxMusicPlayer"

使用

有关详细信息,请参阅ExampleSwiftUI项目Example项目。此外,请参阅下面的用户部分。

示例

example

您可以使用自定义前端实现音频播放器,无需任何代理,如下所示。

基于SwiftUI

import SwiftUI
import Combine
import RxMusicPlayer
import RxSwift
import RxCocoa

final class PlayerModel: ObservableObject {
    private let disposeBag = DisposeBag()
    private let player: RxMusicPlayer
    private let commandRelay = PublishRelay<RxMusicPlayer.Command>()

    @Published var canPlay = true
    @Published var canPlayNext = true
    @Published var canPlayPrevious = true
    @Published var canSkipForward = true
    @Published var canSkipBackward = true
    @Published var title = "Not Playing"
    @Published var artwork: UIImage?
    @Published var restDuration = "--:--"
    @Published var duration = "--:--"
    @Published var shuffleMode = RxMusicPlayer.ShuffleMode.off
    @Published var repeatMode = RxMusicPlayer.RepeatMode.none
    @Published var remoteControl = RxMusicPlayer.RemoteControl.moveTrack

    @Published var sliderValue = Float(0)
    @Published var sliderMaximumValue = Float(0)
    @Published var sliderIsUserInteractionEnabled = false
    @Published var sliderPlayableProgress = Float(0)

    private var cancelBag = Set<AnyCancellable>()
    var sliderValueChanged = PassthroughSubject<Float, Never>()

    init() {
        // 1) Create a player
        let items = [
            URL(string: "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_1473200_1.mp3")!,
            URL(string: "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_2160166.mp3")!,
            URL(string: "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_4690995.mp3")!,
            Bundle.main.url(forResource: "tagmp3_9179181", withExtension: "mp3")!
        ]
        .map({ RxMusicPlayerItem(url: $0) })
        player = RxMusicPlayer(items: items)!

        // 2) Control views
        player.rx.canSendCommand(cmd: .play)
            .do(onNext: { [weak self] canPlay in
                self?.canPlay = canPlay
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .next)
            .do(onNext: { [weak self] canPlayNext in
                self?.canPlayNext = canPlayNext
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .previous)
            .do(onNext: { [weak self] canPlayPrevious in
                self?.canPlayPrevious = canPlayPrevious
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .seek(seconds: 0, shouldPlay: false))
            .do(onNext: { [weak self] canSeek in
                self?.sliderIsUserInteractionEnabled = canSeek
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .skip(seconds: 15))
            .do(onNext: { [weak self] canSkip in
                self?.canSkipForward = canSkip
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .skip(seconds: -15))
            .do(onNext: { [weak self] canSkip in
                self?.canSkipBackward = canSkip
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemDuration()
            .do(onNext: { [weak self] in
                self?.sliderMaximumValue = Float($0?.seconds ?? 0)
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemTime()
            .do(onNext: { [weak self] time in
                self?.sliderValue = Float(time?.seconds ?? 0)
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemLoadedProgressRate()
            .do(onNext: { [weak self] rate in
                self?.sliderPlayableProgress = rate ?? 0
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemTitle()
            .do(onNext: { [weak self] title in
                self?.title = title ?? ""
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemArtwork()
            .do(onNext: { [weak self] artwork in
                self?.artwork = artwork
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemRestDurationDisplay()
            .do(onNext: { [weak self] duration in
                self?.restDuration = duration ?? "--:--"
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.currentItemTimeDisplay()
            .do(onNext: { [weak self] duration in
                if duration == "00:00" {
                    self?.duration = "0:00"
                    return
                }
                self?.duration = duration ?? "--:--"
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.shuffleMode()
            .do(onNext: { [weak self] mode in
                self?.shuffleMode = mode
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.repeatMode()
            .do(onNext: { [weak self] mode in
                self?.repeatMode = mode
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.remoteControl()
            .do(onNext: { [weak self] control in
                self?.remoteControl = control
            })
            .drive()
            .disposed(by: disposeBag)

        // 3) Process the user's input
        player.run(cmd: commandRelay.asDriver(onErrorDriveWith: .empty()))
            .flatMap { status -> Driver<()> in
                switch status {
                case let RxMusicPlayer.Status.failed(err: err):
                    print(err)
                case let RxMusicPlayer.Status.critical(err: err):
                    print(err)
                default:
                    print(status)
                }
                return .just(())
            }
            .drive()
            .disposed(by: disposeBag)

        commandRelay.accept(.prefetch)

        sliderValueChanged
            .removeDuplicates()
            .sink { [weak self] value in
                self?.seek(value: value)
            }
            .store(in: &cancelBag)
    }

    func seek(value: Float?) {
        commandRelay.accept(.seek(seconds: Int(value ?? 0), shouldPlay: false))
    }

    func skip(second: Int) {
        commandRelay.accept(.skip(seconds: second))
    }

    func shuffle() {
        switch player.shuffleMode {
        case .off: player.shuffleMode = .songs
        case .songs: player.shuffleMode = .off
        }
    }

    func play() {
        commandRelay.accept(.play)
    }

    func pause() {
        commandRelay.accept(.pause)
    }

    func playNext() {
        commandRelay.accept(.next)
    }

    func playPrevious() {
        commandRelay.accept(.previous)
    }

    func doRepeat() {
        switch player.repeatMode {
        case .none: player.repeatMode = .one
        case .one: player.repeatMode = .all
        case .all: player.repeatMode = .none
        }
    }

    func toggleRemoteControl() {
        switch remoteControl {
        case .moveTrack:
            player.remoteControl = .skip(second: 15)
        case .skip:
            player.remoteControl = .moveTrack
        }
    }
}

struct PlayerView: View {
    @StateObject private var model = PlayerModel()

    var body: some View {
        ScrollView {
            VStack {
                Spacer()
                    .frame(width: 1, height: 49)

                if let artwork = model.artwork {
                    Image(uiImage: artwork)
                        .resizable()
                        .scaledToFit()
                        .frame(height: 276)
                } else {
                    Spacer()
                        .frame(width: 1, height: 276)
                }

                ProgressSliderView(value: $model.sliderValue,
                                   maximumValue: $model.sliderMaximumValue,
                                   isUserInteractionEnabled: $model.sliderIsUserInteractionEnabled,
                                   playableProgress: $model.sliderPlayableProgress) {
                    model.sliderValueChanged.send($0)
                }
                    .padding(.horizontal)

                HStack {
                    Text(model.duration)
                    Spacer()
                    Text(model.restDuration)
                }
                .padding(.horizontal)

                Spacer()
                    .frame(width: 1, height: 17)

                Text(model.title)

                Spacer()
                    .frame(width: 1, height: 19)

                HStack(spacing: 20.0) {
                    Button(action: {
                        model.shuffle()
                    }) {
                        Text(model.shuffleMode == .off ? "Shuffle" : "No Shuffle")
                    }

                    Button(action: {
                        model.playPrevious()
                    }) {
                        Text("Previous")
                    }
                    .disabled(!model.canPlayPrevious)

                    Button(action: {
                        model.canPlay ? model.play() : model.pause()
                    }) {
                        Text(model.canPlay ? "Play" : "Pause")
                    }

                    Button(action: {
                        model.playNext()
                    }) {
                        Text("Next")
                    }
                    .disabled(!model.canPlayNext)

                    Button(action: {
                        model.doRepeat()
                    }) {
                        Text({
                            switch model.repeatMode {
                            case .none: return "Repeat"
                            case .one: return "Repeat(All)"
                            case .all: return "No Repeat"
                            }
                        }() as String)
                    }
                }

                Group {
                    Spacer()
                        .frame(width: 1, height: 17)

                    HStack(spacing: 20.0) {
                        Button(action: {
                            model.skip(second: -15)
                        }) {
                            Text("SkipBackward")
                        }
                        .disabled(!model.canSkipBackward)

                        Button(action: {
                            model.skip(second: 15)
                        }) {
                            Text("SkipForward")
                        }
                        .disabled(!model.canSkipForward)
                    }
                }

                Group {
                    Spacer()
                        .frame(width: 1, height: 17)

                    HStack(spacing: 20.0) {
                        Button(action: {
                            model.toggleRemoteControl()
                        }) {
                            let control = model.remoteControl == .moveTrack ? "moveTrack" : "skip"
                            Text("RemoteControl: \(control)")
                        }
                    }
                }
            }
        }
    }
}

struct PlayerView_Previews: PreviewProvider {
    static var previews: some View {
        PlayerView()
    }
}

基于UIKit

import RxCocoa
import RxMusicPlayer
import RxSwift
import UIKit

class TableViewController: UITableViewController {

    @IBOutlet private var playButton: UIButton!
    @IBOutlet private var nextButton: UIButton!
    @IBOutlet private var prevButton: UIButton!
    @IBOutlet private var titleLabel: UILabel!
    @IBOutlet private var artImageView: UIImageView!
    @IBOutlet private var lyricsLabel: UILabel!
    @IBOutlet private var seekBar: ProgressSlider!
    @IBOutlet private var seekDurationLabel: UILabel!
    @IBOutlet private var durationLabel: UILabel!
    @IBOutlet private var shuffleButton: UIButton!
    @IBOutlet private var repeatButton: UIButton!
    @IBOutlet private var rateButton: UIButton!
    @IBOutlet private var appendButton: UIButton!
    @IBOutlet private var changeButton: UIButton!

    private let disposeBag = DisposeBag()

    // swiftlint:disable cyclomatic_complexity
    override func viewDidLoad() {
        super.viewDidLoad()

        // 1) Create a player
        let items = [
            "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_1473200_1.mp3",
            "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_2160166.mp3",
            "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_4690995.mp3",
            "https://storage.googleapis.com/great-dev/oss/musicplayer/tagmp3_9179181.mp3",
            "https://storage.googleapis.com/great-dev/oss/musicplayer/bensound-extremeaction.mp3",
            "https://storage.googleapis.com/great-dev/oss/musicplayer/bensound-littleplanet.mp3",
        ]
        .map({ RxMusicPlayerItem(url: URL(string: $0)!) })
        let player = RxMusicPlayer(items: Array(items[0 ..< 4]))!

        // 2) Control views
        player.rx.canSendCommand(cmd: .play)
            .do(onNext: { [weak self] canPlay in
                self?.playButton.setTitle(canPlay ? "Play" : "Pause", for: .normal)
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .next)
            .drive(nextButton.rx.isEnabled)
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .previous)
            .drive(prevButton.rx.isEnabled)
            .disposed(by: disposeBag)

        player.rx.canSendCommand(cmd: .seek(seconds: 0, shouldPlay: false))
            .drive(seekBar.rx.isUserInteractionEnabled)
            .disposed(by: disposeBag)

        player.rx.currentItemTitle()
            .drive(titleLabel.rx.text)
            .disposed(by: disposeBag)

        player.rx.currentItemArtwork()
            .drive(artImageView.rx.image)
            .disposed(by: disposeBag)

        player.rx.currentItemLyrics()
            .distinctUntilChanged()
            .do(onNext: { [weak self] _ in
                self?.tableView.reloadData()
            })
            .drive(lyricsLabel.rx.text)
            .disposed(by: disposeBag)

        player.rx.currentItemRestDurationDisplay()
            .map {
                guard let rest = $0 else { return "--:--" }
                return "-\(rest)"
            }
            .drive(durationLabel.rx.text)
            .disposed(by: disposeBag)

        player.rx.currentItemTimeDisplay()
            .drive(seekDurationLabel.rx.text)
            .disposed(by: disposeBag)

        player.rx.currentItemDuration()
            .map { Float($0?.seconds ?? 0) }
            .do(onNext: { [weak self] in
                self?.seekBar.maximumValue = $0
            })
            .drive()
            .disposed(by: disposeBag)

        let seekValuePass = BehaviorRelay<Bool>(value: true)
        player.rx.currentItemTime()
            .withLatestFrom(seekValuePass.asDriver()) { ($0, $1) }
            .filter { $0.1 }
            .map { Float($0.0?.seconds ?? 0) }
            .drive(seekBar.rx.value)
            .disposed(by: disposeBag)
        seekBar.rx.controlEvent(.touchDown)
            .do(onNext: {
                seekValuePass.accept(false)
            })
            .subscribe()
            .disposed(by: disposeBag)
        seekBar.rx.controlEvent(.touchUpInside)
            .do(onNext: {
                seekValuePass.accept(true)
            })
            .subscribe()
            .disposed(by: disposeBag)

        player.rx.currentItemLoadedProgressRate()
            .drive(seekBar.rx.playableProgress)
            .disposed(by: disposeBag)

        player.rx.shuffleMode()
            .do(onNext: { [weak self] mode in
                self?.shuffleButton.setTitle(mode == .off ? "Shuffle" : "No Shuffle", for: .normal)
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.repeatMode()
            .do(onNext: { [weak self] mode in
                var title = ""
                switch mode {
                case .none: title = "Repeat"
                case .one: title = "Repeat(All)"
                case .all: title = "No Repeat"
                }
                self?.repeatButton.setTitle(title, for: .normal)
            })
            .drive()
            .disposed(by: disposeBag)

        player.rx.playerIndex()
            .do(onNext: { index in
                if index == player.queuedItems.count - 1 {
                    // You can remove the comment-out below to confirm the append().
                    // player.append(items: items)
                }
            })
            .drive()
            .disposed(by: disposeBag)

        // 3) Process the user's input
        let cmd = Driver.merge(
            playButton.rx.tap.asDriver().map { [weak self] in
                if self?.playButton.currentTitle == "Play" {
                    return RxMusicPlayer.Command.play
                }
                return RxMusicPlayer.Command.pause
            },
            nextButton.rx.tap.asDriver().map { RxMusicPlayer.Command.next },
            prevButton.rx.tap.asDriver().map { RxMusicPlayer.Command.previous },
            seekBar.rx.controlEvent(.valueChanged).asDriver()
                .map { [weak self] _ in
                    RxMusicPlayer.Command.seek(seconds: Int(self?.seekBar.value ?? 0),
                                               shouldPlay: false)
                }
                .distinctUntilChanged()
        )
        .startWith(.prefetch)
        .debug()

        // You can remove the comment-out below to confirm changing the current index of music items.
        // Default is 0.
        // player.playIndex = 1

        player.run(cmd: cmd)
            .do(onNext: { status in
                UIApplication.shared.isNetworkActivityIndicatorVisible = status == .loading
            })
            .flatMap { [weak self] status -> Driver<()> in
                guard let weakSelf = self else { return .just(()) }

                switch status {
                case let RxMusicPlayer.Status.failed(err: err):
                    print(err)
                    return Wireframe.promptOKAlertFor(src: weakSelf,
                                                      title: "Error",
                                                      message: err.localizedDescription)

                case let RxMusicPlayer.Status.critical(err: err):
                    print(err)
                    return Wireframe.promptOKAlertFor(src: weakSelf,
                                                      title: "Critical Error",
                                                      message: err.localizedDescription)
                default:
                    print(status)
                }
                return .just(())
            }
            .drive()
            .disposed(by: disposeBag)

        shuffleButton.rx.tap.asDriver()
            .drive(onNext: {
                switch player.shuffleMode {
                case .off: player.shuffleMode = .songs
                case .songs: player.shuffleMode = .off
                }
            })
            .disposed(by: disposeBag)

        repeatButton.rx.tap.asDriver()
            .drive(onNext: {
                switch player.repeatMode {
                case .none: player.repeatMode = .one
                case .one: player.repeatMode = .all
                case .all: player.repeatMode = .none
                }
            })
            .disposed(by: disposeBag)

        rateButton.rx.tap.asDriver()
            .flatMapLatest { [weak self] _ -> Driver<()> in
                guard let weakSelf = self else { return .just(()) }

                return Wireframe.promptSimpleActionSheetFor(
                    src: weakSelf,
                    cancelAction: "Close",
                    actions: PlaybackRateAction.allCases.map {
                        ($0.rawValue, player.desiredPlaybackRate == $0.toFloat)
                })
                    .do(onNext: { [weak self] action in
                        if let rate = PlaybackRateAction(rawValue: action)?.toFloat {
                            player.desiredPlaybackRate = rate
                            self?.rateButton.setTitle(action, for: .normal)
                        }
                    })
                    .map { _ in }
            }
            .drive()
            .disposed(by: disposeBag)

        appendButton.rx.tap.asDriver()
            .do(onNext: {
                let newItems = Array(items[4 ..< 6])
                player.append(items: newItems)
            })
            .drive(onNext: { [weak self] _ in
                self?.appendButton.isEnabled = false
            })
            .disposed(by: disposeBag)

        changeButton.rx.tap.asObservable()
            .flatMapLatest { [weak self] _ -> Driver<()> in
                guard let weakSelf = self else { return .just(()) }

                return Wireframe.promptSimpleActionSheetFor(
                    src: weakSelf,
                    cancelAction: "Close",
                    actions: items.map {
                        ($0.url.lastPathComponent, player.queuedItems.contains($0))
                })
                    .asObservable()
                    .do(onNext: { action in
                        if let idx = player.queuedItems.map({ $0.url.lastPathComponent }).firstIndex(of: action) {
                            try player.remove(at: idx)
                        } else if let idx = items.map({ $0.url.lastPathComponent }).firstIndex(of: action) {
                            for i in (0 ... idx).reversed() {
                                if let prev = player.queuedItems.firstIndex(of: items[i]) {
                                    player.insert(items[idx], at: prev + 1)
                                    break
                                }
                                if i == 0 {
                                    player.insert(items[idx], at: 0)
                                }
                            }
                        }

                        self?.appendButton.isEnabled = !(player.queuedItems.contains(items[4])
                            || player.queuedItems.contains(items[5]))
                    })
                    .asDriver(onErrorJustReturn: "")
                    .map { _ in }
            }
            .asDriver(onErrorJustReturn: ())
            .drive()
            .disposed(by: disposeBag)
    }
}

用户

贡献

  • 分支合并
  • 运行 make bootstrap
  • 创建你的功能分支:git checkout -b your-new-feature
  • 提交更改:git commit -m '添加你的功能'
  • 推送到分支:git push origin your-new-feature
  • 提交拉取请求

发布

  • 在GitHub上创建一个新的发布
  • 在CocoaPods上发布一个新的podspec
    • bundle exec pod trunk push RxMusicPlayer.podspec

错误报告

尽管任何错误报告都有帮助,但有时没有可复现的项目就很难确定原因。

特别是,由于RxMusicPlayer依赖于容易犯错的RxSwift,因此解除耦合问题更为重要。

因此,我强烈建议您提交该项目的相关问题描述。

以下是创建描述的方法。

  • 分支合并
  • 创建你的功能分支:git checkout -b your-bug-name
  • 在Example目录下添加一些更改以复现错误
  • 提交更改:git commit -m '添加可复现功能'
  • 推送到分支:git push origin your-bug-name
  • (可选)提交拉取请求
  • 在问题中分享

代码不应交织,应简洁、直接且简单。

注意:如果您无法准备可复现的代码,则必须具体清楚地描述细节,以便我能够复现问题。

许可

MIT许可(MIT)

致谢

感谢以下项目和创作者。