RxMusicPlayer
RxMusicPlayer 是RxSwift支持的avplayer的封装,使音频播放变得容易。
特点
- 遵循 受用户控制的播放和录音应用的音频指南。
- 支持远程和本地音频文件的流式播放。
- 提供
play
、pause
、stop
、play next
、play previous
、skip forward/backward
、prefetch metadata
、repeat mode(repeat, repeat all)
、shuffle mode
、desired playback rate
、seek to a certain second
和append/insert/remove an item into the playlist
功能。 - 加载元数据,包括
title
、album
、artist
、artwork
、duration
和lyrics
。 - 与 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项目。此外,请参阅下面的用户部分。
示例
您可以使用自定义前端实现音频播放器,无需任何代理,如下所示。
基于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)
}
}
用户
- AMMusicPlayerController
贡献
- 分支合并
- 运行
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)
致谢
感谢以下项目和创作者。
- Jukebox: https://github.com/teodorpatras/Jukebox
- 我受到了这个库的界面和部分实现的启发。
- RxAudioVisual: https://github.com/keitaoouchi/RxAudioVisual
- 由于适配最新的Swift编译器,我参考了一些实现而不是完全依赖它们。
- Smith, J.O. 物理音频信号处理: https://ccrma.stanford.edu/~jos/waveguide/Sound_Examples.html
- 该项目使用这些作为示例MP3文件。
- file-examples.com: https://file-examples.com/index.php/sample-audio-files/sample-mp3-download/
- 该项目使用其中一个作为示例MP3文件。