I'm trying to play an audiofile and control it's playback via the Remote Command Centre available on the lock screen.
If I do the following:
Begin playback
Pause playback
Lock device
Begin playback from lockscreen (MPRemoteCommandCenter)
It is then impossible to pause playback from lockscreen. The button flickers and nothing happens.
How can I fix this?
Further details below:
It appears that when attempting to pause the audio, the AVAudioPlayer returns 'false' for audioPlayer.isPlaying.
This occurs on iOS13.1 on my iPhoneXR, iPhoneSE and iPhone8. I have no other devices to test against
The logs indicate that AVAudioPlayer.isPlaying initially returns true when playback is started, but subsequently returns false. The player's currentTime also appears stuck at around the time playback was started.
My entire view controller is below (~100 lines). This is the minimum necessary to reproduce the problem.
The this example project demonstrating the error is also available on Github here.
import UIKit
import MediaPlayer
class ViewController: UIViewController {
#IBOutlet weak var playPauseButton: UIButton!
#IBAction func playPauseButtonTap(_ sender: Any) {
if self.audioPlayer.isPlaying {
pause()
} else {
play()
}
}
private var audioPlayer: AVAudioPlayer!
private var hasPlayed = false
override func viewDidLoad() {
super.viewDidLoad()
let fileUrl = Bundle.main.url(forResource: "temp/intro", withExtension: ".mp3")
try! self.audioPlayer = AVAudioPlayer(contentsOf: fileUrl!)
let audioSession = AVAudioSession.sharedInstance()
do { // play on speakers if headphones not plugged in
try audioSession.overrideOutputAudioPort(AVAudioSession.PortOverride.speaker)
} catch let error as NSError {
print("Override headphones failed, probably because none are available: \(error.localizedDescription)")
}
do {
try audioSession.setCategory(.playback, mode: .spokenAudio)
try audioSession.setActive(true)
} catch let error as NSError {
print("Warning: Setting audio category to .playback|.spokenAudio failed: \(error.localizedDescription)")
}
playPauseButton.setTitle("Play", for: .normal)
}
func play() {
playPauseButton.setTitle("Pause", for: .normal)
self.audioPlayer.play()
if(!hasPlayed){
self.setupRemoteTransportControls()
self.hasPlayed = true
}
}
func pause() {
playPauseButton.setTitle("Play", for: .normal)
self.audioPlayer.pause()
}
// MARK: Remote Transport Protocol
#objc private func handlePlay(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
print(".......................")
print(self.audioPlayer.currentTime)
let address = Unmanaged.passUnretained(self.audioPlayer).toOpaque()
print("\(address) not playing: \(!self.audioPlayer.isPlaying)")
guard !self.audioPlayer.isPlaying else { return .commandFailed }
print("attempting to play")
let success = self.audioPlayer.play()
print("play() invoked with success \(success)")
print("now playing \(self.audioPlayer.isPlaying)")
return success ? .success : .commandFailed
}
#objc private func handlePause(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
print(".......................")
print(self.audioPlayer.currentTime)
let address = Unmanaged.passUnretained(self.audioPlayer).toOpaque()
print("\(address) playing: \(self.audioPlayer.isPlaying)")
guard self.audioPlayer.isPlaying else { return .commandFailed }
print("attempting to pause")
self.pause()
print("pause() invoked")
return .success
}
private func setupRemoteTransportControls() {
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.addTarget(self, action: #selector(self.handlePlay))
commandCenter.pauseCommand.addTarget(self, action: #selector(self.handlePause))
var nowPlayingInfo = [String : Any]()
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = "Major title"
nowPlayingInfo[MPMediaItemPropertyTitle] = "Minor Title"
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.audioPlayer.currentTime
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = self.audioPlayer.duration
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = self.audioPlayer.rate
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
}
This logs the following (with my // comments added):
.......................
1.438140589569161 // audio was paused here
0x0000000283361cc0 not playing: true // player correctly says its not playing
attempting to play // so it'll start to play
play() invoked with success true // play() successfully invoked
now playing true // and the player correctly reports it's playing
.......................
1.4954875283446711 // The player thinks it's being playing for about half a second
0x0000000283361cc0 playing: false // and has now paused??? WTF?
.......................
1.4954875283446711 // but there's definitely sound coming from the speakers. It has **NOT** paused.
0x0000000283361cc0 playing: false // yet it thinks it's paused?
// note that the memory addresses are the same. This seems to be the same player. ='(
I'm at my wits' end. Help me StackOverflow—You're my only hope.
Edits: I've also tried
Always returning .success
#objc private func handlePlay(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
guard !self.audioPlayer.isPlaying else { return .success }
self.audioPlayer.play()
return .success
}
#objc private func handlePause(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
print(self.audioPlayer.isPlaying)
guard self.audioPlayer.isPlaying else { return .success }
self.pause()
return .success
}
Ignoring the audioPlayer state and just doing as the remote command centre says
#objc private func handlePlay(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
self.audioPlayer.play()
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.audioPlayer.currentTime
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = self.audioPlayer.rate
return .success
}
#objc private func handlePause(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus {
self.audioPlayer.pause()
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = self.audioPlayer.currentTime
MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = self.audioPlayer.rate
return .success
}
Both of these result in the bug persisting the first time pause is tapped on the lock screen. Subsequent taps reset the audio to the original paused position and then work normally.
Update
Replacing AVAudioPlayer with AVPlayer appears to make the problem go away entirely! I'm reasonably sure this is a bug in AVAudioPlayer now.
The necessary steps to switch to AVPlayer are in this public diff
I've used one of my developer-question tickets and submitted this question to Apple. I'll post an answer when I hear back from them.
Update 2
Apple Dev Support confirmed that as of 5 Dec 2019 there's no known workaround for this issue. I've submitted an issue to feedbackassistant.apple.com and will update this answer when something changes.
This is indeed a bug in AVAudioPlayer.
Another workaround if you dont want to switch to AVPlayer is to simply check if playing before pausing and if not, call play just before pause. It's not pretty but it works:
if (!self.player.isPlaying) [self.player play];
[self.player pause];
Related
The environment for this is iOS 13.6 and Swift 5. I have a very simple app that successfully plays an MP3 file in the foreground or background. I added MPRemoteCommandCenter play and pause command handlers to it. I play the sound file in the foreground and then pause it.
When I tap the play button from the lock screen, my code calls audioPlayer.play(), which returns true. I hear the sound start playing again, but the currentTime of the player does not advance. After that, the play and pause buttons on the lock screen do nothing. When I foreground the app again, the play button plays from where it was before I went to the lock screen.
Here is my AudioPlayer class:
import AVFoundation
import MediaPlayer
class AudioPlayer: RemoteAudioCommandDelegate {
var audioPlayer = AVAudioPlayer()
let remoteCommandHandler = RemoteAudioCommandHandler()
var timer:Timer!
func play(title: String) {
let path = Bundle.main.path(forResource: title, ofType: "mp3")!
let url = URL(fileURLWithPath: path)
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
try AVAudioSession.sharedInstance().setActive(true)
audioPlayer = try AVAudioPlayer(contentsOf: url)
remoteCommandHandler.delegate = self
remoteCommandHandler.enableDisableRemoteCommands(true)
timer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(updateNowPlayingInfo), userInfo: nil, repeats: true)
} catch let error as NSError {
print("error = \(error)")
}
}
func play() {
print ("audioPlayer.play() returned \(audioPlayer.play())")
}
func pause() {
audioPlayer.pause()
}
func stop() {
audioPlayer.stop()
}
func currentTime() -> TimeInterval {
return audioPlayer.currentTime
}
func setCurrentTime(_ time:TimeInterval) {
audioPlayer.currentTime = time
}
#objc func updateNowPlayingInfo() {
// Hard-code the nowPlayingInfo since this is a simple test app
var nowPlayingDict =
[MPMediaItemPropertyTitle: "Tin Man",
MPMediaItemPropertyAlbumTitle: "The Complete Greatest Hits",
MPMediaItemPropertyAlbumTrackNumber: NSNumber(value: UInt(10) as UInt),
MPMediaItemPropertyArtist: "America",
MPMediaItemPropertyPlaybackDuration: 208,
MPNowPlayingInfoPropertyPlaybackRate: NSNumber(value: 1.0 as Float)] as [String : Any]
nowPlayingDict[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: audioPlayer.currentTime as Double)
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingDict
}
}
Here is my RemoteCommandHandler class:
import Foundation
import MediaPlayer
protocol RemoteAudioCommandDelegate: class {
func play()
func pause()
}
class RemoteAudioCommandHandler: NSObject {
weak var delegate: RemoteAudioCommandDelegate?
var remoteCommandCenter = MPRemoteCommandCenter.shared()
var playTarget: Any? = nil
var pauseTarget: Any? = nil
func enableDisableRemoteCommands(_ enabled: Bool) {
print("Called with enabled = \(enabled)")
remoteCommandCenter.playCommand.isEnabled = enabled
remoteCommandCenter.pauseCommand.isEnabled = enabled
if enabled {
addRemoteCommandHandlers()
} else {
removeRemoteCommandHandlers()
}
}
fileprivate func addRemoteCommandHandlers() {
print( "Entered")
if playTarget == nil {
print( "Adding playTarget")
playTarget = remoteCommandCenter.playCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
print("addRemoteCommandHandlers calling delegate play")
self.delegate?.play()
return .success
}
}
if pauseTarget == nil {
print( "Adding pauseTarget")
pauseTarget = remoteCommandCenter.pauseCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
print("addRemoteCommandHandlers calling delegate pause")
self.delegate?.pause()
return .success
}
}
}
fileprivate func removeRemoteCommandHandlers() {
print( "Entered")
if playTarget != nil {
print( "Removing playTarget")
remoteCommandCenter.playCommand.removeTarget(playTarget)
playTarget = nil
}
if pauseTarget != nil {
print( "Removing pauseTarget")
remoteCommandCenter.pauseCommand.removeTarget(pauseTarget)
pauseTarget = nil
}
}
}
I will gladly supply further required info, because I'm baffled at why this relatively straightforward code (in my mind) code doesn't work.
Assistance is much appreciated!
After some more debugging, I found that the AVAudioPlayer started to play the sound from the lock screen, but stopped again shortly after.
I mitigated the problem by adding a Timer. The timer checks if the last command by the user was play, but the sound is not playing. I also change the status when the user selects pause or the song stops playing at its natural end.
I am still at a loss for an actual fix for this problem.
I'm working on an app that play audio in Apple Watch; All working fine from inside the app.
I'm trying to setup the MPRemoteCommandCenter in 'Now Playing' to change the next/prev track to skipForward/skipBackward and to add a handler for the pause command.
Nothing change in Now Playing commands the the handler not being trigged.
Below a snippet code:
Play method
var player: WKAudioFilePlayer!
#IBAction func play() {
let avSession = AVAudioSession.sharedInstance()
try! avSession.setCategory(.playback, mode: .default, policy: .longForm, options: [])
let url = Bundle.main.url(forResource: "sample", withExtension: "mp3")!
let item = WKAudioFilePlayerItem(asset: WKAudioFileAsset(url: url))
player = WKAudioFilePlayer(playerItem: item)
do {
try avSession.setActive(true, options: .notifyOthersOnDeactivation)
player.play()
} catch {
print("Error")
}
}
Remote controls setup method:
func setupRemoteControls() {
// Get the shared MPRemoteCommandCenter
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: 15)]
commandCenter.skipForwardCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
// skip forward
return .success
}
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: 15)]
commandCenter.skipBackwardCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
// skip backword
return .success
}
commandCenter.skipBackwardCommand.isEnabled = true
// Add handler for play Command
commandCenter.playCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
self.player.play()
return .success
}
// Add handler for Pause Command
commandCenter.pauseCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
self.player.pause()
return .success
}
}
I'm calling self.setupRemoteControls() in awake method. Also I tried to move the setup to the ExtensionDelegate -> applicationDidFinishLaunching
Apple references I used:
https://developer.apple.com/videos/play/wwdc2018/504/
https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/MediaPlaybackGuide/Contents/Resources/en.lproj/RefiningTheUserExperience/RefiningTheUserExperience.html
:: UPDATE ::
I found when using the AVAudioPlayer instead of WKAudioFilePlayer the setup for MPRemoteCommandCenter working fine.
The problem that I don't use local audio files!.. I stream 'm3u8' files using wowza which worked only with WKAudioFilePlayer.
And if I tried to stream using AVAudioPlayer I get this error
The operation couldn’t be completed. (OSStatus error 2003334207.)
So my issue now to get MPRemoteCommandCenter being configured while still using WKAudioFilePlayer, or find a way to stream using AVAudioPlayer ??
I am currently working on a method that will play a mp4 file when called. However, while the mp4 file is being played I want user interactions to be disabled. This is so that the user cannot tap the screen while the mp4 is playing and start the same AVAudioSession over again. My code thus far is this
import Foundation; import UIKit; import AVFoundation
var player: AVAudioPlayer?
class Card: NSObject
{
var image: UIImage
var soundUrl: String
init(image: UIImage, soundUrl: String, isActive:Bool = true) {
self.image = image
self.soundUrl = soundUrl
}
func playSound()
{
guard let url = Bundle.main.url(forResource: self.soundUrl, withExtension: "m4a") else { return }
do
{
try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
try AVAudioSession.sharedInstance().setActive(true)
player = try AVAudioPlayer(contentsOf: url)
guard let player = player else { return }
player.prepareToPlay()
player.play()
print("play")
} catch let error {
print(error.localizedDescription)
}
}
}
You're missing any references to the UI elements you want to disable. You're just going to set button.isEnabled = false on the them, and set them back when the player is done. You'll know the player is done by setting your object as its delegate and then implementing func audioPlayerDidFinishPlaying(_:successfully) to turn things back on.
As a note, there is never a reason to call prepareToPlay() immediately before calling play(). play() prepares itself.
player.delegate = self
extension Card: AVAudioPlayerDelegate {
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool)
// re-enable UI
}
}
You can call self.view.isUserInteractionEnabled = false after playing the audio.
After that, use self.view.isUserInteractionEnabled = true on audioPlayerDidFinishPlaying(_:successfully)
To disable all incoming touches on the device, you can call UIApplication.shared.beginIgnoringInteractionEvents() and call UIApplication.shared.endIgnoringInteractionEvents() to reenable touches. This is typically called during animations but this is what you asked for.
I'm trying to control my audio player from outside of the app,
I started an av audio session but it's not playing on the background(worked fine on swift 3),
do{
myPlayer = AVPlayer(url: url!)
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(AVAudioSessionCategoryPlayback
)
do {
try audioSession.setActive(true)
}
}
catch {
print(error)
}
}
my main goal is to control play and pause like this:
So you are building an app that plays audio on the background using AVPlayer. You should use MPNowPlayingInfoCenter to display the song's metadata on the Lock Screen and Control Center, and use MPRemoteCommandCenter to control the previous/next/play/pause actions on the lock screen and control center.
Enable Background Mode for Audio, AirPlay and Picture in
Picture in your Target > Capabilities.
If you're streaming audio from the web, enable Background Mode for Background Fetch too.
Import AVKit and MediaPlayer dependencies.
Setup your AVPlayer with an AVPlayerItem:
guard let url = URL(string: "http://your.website.com/playlist.m3u8") else {
return
}
player = AVPlayer(url: url)
Setup your AVAudioSession:
private func setupAVAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
try AVAudioSession.sharedInstance().setActive(true)
debugPrint("AVAudioSession is Active and Category Playback is set")
UIApplication.shared.beginReceivingRemoteControlEvents()
setupCommandCenter()
} catch {
debugPrint("Error: \(error)")
}
}
Setup the InfoCenter and the RemoteCommandCenter:
private func setupCommandCenter() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = [MPMediaItemPropertyTitle: "Your App Name"]
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.pauseCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] (event) -> MPRemoteCommandHandlerStatus in
self?.player.play()
return .success
}
commandCenter.pauseCommand.addTarget { [weak self] (event) -> MPRemoteCommandHandlerStatus in
self?.player.pause()
return .success
}
}
You can place the setupAVAudioSession() method in your viewDidLoad() method, or in any other place you need it.
If you need to place more info in the MPNowPlayingInfoCenter here's a list of all available properties: General Media Item Property Keys | Apple Developer Documentation
I want to know how to get the state of my player (AVPlayer) (buffering, playing, stopped, error) and update the ui according to those states (including the player on the lock screen). How exactly should I do it?
I have a label that may contain:
"Buffering...", "Playing", "Stopped" or "Error".
Basically, I have the following:
MediaPlayer:
import Foundation
import AVFoundation
class MediaPlayer {
static let sharedInstance = MediaPlayer()
fileprivate var player = AVPlayer(url: URL(string: "my_hls_stream_url_here")!)
fileprivate var isPlaying = false
func play() {
player.play()
isPlaying = true
}
func pause() {
player.pause()
isPlaying = false
}
func toggle() {
if isPlaying == true {
pause()
} else {
play()
}
}
func currentlyPlaying() -> Bool {
return isPlaying
}
}
PlayerViewController:
class PlayerViewController: UIViewController {
#IBOutlet weak var label: UILabel!
#IBAction func playStopButtonAction(_ sender: UIButton) {
MediaPlayer.sharedInstance.toggle()
}
override func viewDidLoad() {
super.viewDidLoad()
label.text = "Disconnected"
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
try AVAudioSession.sharedInstance().setActive(true)
print("Audio session ok\n")
} catch {
print("Error: Audio session.\n")
}
// Show only play/pause button on the lock screen
if #available(iOS 9.1, *) {
let center = MPRemoteCommandCenter.shared()
[center.previousTrackCommand, center.nextTrackCommand, center.seekForwardCommand, center.seekBackwardCommand, center.skipForwardCommand, center.skipBackwardCommand, center.ratingCommand, center.changePlaybackRateCommand, center.likeCommand, center.dislikeCommand, center.bookmarkCommand, center.changePlaybackPositionCommand].forEach {
$0.isEnabled = false
}
center.togglePlayPauseCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in
MediaPlayer.sharedInstance.toggle()
return MPRemoteCommandHandlerStatus.success
}
center.playCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in
MediaPlayer.sharedInstance.play()
return MPRemoteCommandHandlerStatus.success
}
center.pauseCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in
MediaPlayer.sharedInstance.pause()
return MPRemoteCommandHandlerStatus.success
}
} else {
// Fallback on earlier versions
print("Error (MPRemoteCommandCenter)")
}
}
override func remoteControlReceived(with event: UIEvent?) {
guard let event = event else {
print("No event\n")
return
}
guard event.type == UIEventType.remoteControl else {
print("Another event received\n")
return
}
switch event.subtype {
case UIEventSubtype.remoteControlPlay:
print("'Play' event received\n")
case UIEventSubtype.remoteControlPause:
print("'Pause' event received\n")
case UIEventSubtype.remoteControlTogglePlayPause:
print("'Toggle' event received\n")
default:
print("\(event.subtype)\n")
}
}
}
I think you could use the timeControlStatus property of AVPlayer. According to the doc it can be paused, waitingToPlayAtSpecifiedRate which is basically what you call buffering or playing.
If you really need the error state, you could observe the error property or whether the status property is set to failed.
A simple KVO observer on these properties would do the trick.
A place to start could be through using the AVPlayer's "status" property. It is an enumeration that contains the following values (this is taken directly from the documentation):
'unknown': Indicates that the status of the player is not yet known because it has not tried to load new media resources for playback.
'readyToPlay': Indicates that the player is ready to play AVPlayerItem instances.
'failed': Indicates that the player can no longer play AVPlayerItem instances because of an error.
As to how you could tell if the content is actually playing, you could just use boolean checks as it seems you have partially implemented. For pausing and stopping, you could just keep the file loaded for pause, and delete the file for stop that way you could differentiate the two.
For buffering, if the enum is not unknown or readyToPlay then that theoretically should mean that there is a file being attached but is not quite ready to play (i.e. buffering).