Signal-iOS/SignalUI/AV/AudioPlayer.swift
2024-03-25 14:22:28 -05:00

353 lines
10 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVFoundation
import MediaPlayer
import SignalCoreKit
import SignalServiceKit
public enum AudioBehavior {
case unknown
case playback
case playbackMixWithOthers
case audioMessagePlayback
case playAndRecord
case call
}
public enum AudioPlaybackState {
case stopped
case playing
case paused
}
public protocol AudioPlayerDelegate: AnyObject {
var audioPlaybackState: AudioPlaybackState { get set }
func setAudioProgress(_ progress: TimeInterval, duration: TimeInterval, playbackRate: Float)
func audioPlayerDidFinish()
}
public class AudioPlayer: NSObject {
public weak var delegate: AudioPlayerDelegate?
public var duration: TimeInterval {
audioPlayer?.duration ?? 0
}
// 1 (default) is normal playback speed. 0.5 is half speed, 2.0 is twice as fast.
public var playbackRate: Float = 1 {
didSet {
if let audioPlayer, oldValue != playbackRate {
audioPlayer.rate = playbackRate
}
}
}
public var isLooping: Bool = false
private let mediaUrl: URL
private var audioPlayer: AVAudioPlayer?
private var audioPlayerPoller: Timer?
private let audioActivity: AudioActivity
public init(mediaUrl: URL, audioBehavior: AudioBehavior) {
self.mediaUrl = mediaUrl
audioActivity = AudioActivity(audioDescription: "\(Self.logTag()) \(mediaUrl)", behavior: audioBehavior)
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil
)
}
deinit {
DeviceSleepManager.shared.removeBlock(blockObject: self)
stop()
}
// MARK: - Playback
public func play() {
AssertIsOnMainThread()
let success = audioSession.startAudioActivity(audioActivity)
owsAssertDebug(success)
setupAudioPlayer()
setupRemoteCommandCenter()
delegate?.audioPlaybackState = .playing
audioPlayer?.play()
audioPlayerPoller?.invalidate()
let audioPlayerPoller = Timer.weakTimer(
withTimeInterval: 0.05,
target: self,
selector: #selector(audioPlayerUpdated(timer:)),
userInfo: nil,
repeats: true
)
RunLoop.main.add(audioPlayerPoller, forMode: .common)
self.audioPlayerPoller = audioPlayerPoller
// Prevent device from sleeping while playing audio.
DeviceSleepManager.shared.addBlock(blockObject: self)
}
public func pause() {
AssertIsOnMainThread()
guard let audioPlayer else {
owsFailDebug("audioPlayer == nil")
return
}
delegate?.audioPlaybackState = .paused
audioPlayer.pause()
audioPlayerPoller?.invalidate()
delegate?.setAudioProgress(audioPlayer.currentTime, duration: audioPlayer.duration, playbackRate: playbackRate)
updateNowPlayingInfo()
endAudioActivities()
DeviceSleepManager.shared.removeBlock(blockObject: self)
}
public func setupAudioPlayer() {
AssertIsOnMainThread()
guard (delegate?.audioPlaybackState ?? .stopped) == .stopped else { return }
guard audioPlayer == nil else {
if delegate?.audioPlaybackState == .stopped {
delegate?.audioPlaybackState = .paused
}
return
}
// In some cases, Android sends audio messages with the "audio/mpeg" content type. This
// makes our choice of file extension ambiguous`.mp3` or `.m4a`? AVFoundation uses the
// extension to read the file, and if the extension is wrong, it won't be playable.
//
// In this case, we use a file type hint to tell AVFoundation to try the other type. This
// is brittle but necessary to work around the buggy marriage of Android's content type and
// AVFoundation's default behavior.
//
// Note that we probably still want this code even if Android updates theirs, because
// iOS users might have existing attachments.
//
// See a similar comment in `AudioWaveformManager` and
// <https://github.com/signalapp/Signal-iOS/issues/3590>.
let fileTypeHint: AVFileType?
lazy var isReadable = AVURLAsset(url: mediaUrl).isReadable
switch mediaUrl.pathExtension {
case "mp3": fileTypeHint = isReadable ? nil : AVFileType.m4a
case "m4a": fileTypeHint = isReadable ? nil : AVFileType.mp3
default: fileTypeHint = nil
}
let audioPlayer: AVAudioPlayer
do {
audioPlayer = try AVAudioPlayer(
contentsOf: mediaUrl,
fileTypeHint: fileTypeHint?.rawValue
)
} catch let error as NSError {
Logger.error("Error: \(error)")
stop()
if error.domain == NSOSStatusErrorDomain {
if error.code == kAudioFileInvalidFileError || error.code == kAudioFileStreamError_InvalidFile {
OWSActionSheets.showErrorAlert(
message: OWSLocalizedString(
"INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE",
comment: "Message for the alert indicating that an audio file is invalid."
)
)
}
}
return
}
audioPlayer.delegate = self
// Always enable playback rate from the start; it can only
// be set before playing begins.
audioPlayer.enableRate = true
audioPlayer.rate = playbackRate
audioPlayer.prepareToPlay()
if isLooping {
audioPlayer.numberOfLoops = -1
}
self.audioPlayer = audioPlayer
if delegate?.audioPlaybackState == .stopped {
delegate?.audioPlaybackState = .paused
}
}
public func stop() {
delegate?.audioPlaybackState = .stopped
audioPlayer?.pause()
audioPlayerPoller?.invalidate()
delegate?.setAudioProgress(0, duration: 0, playbackRate: playbackRate)
endAudioActivities()
DeviceSleepManager.shared.removeBlock(blockObject: self)
teardownRemoteCommandCenter()
}
private func endAudioActivities() {
audioSession.endAudioActivity(audioActivity)
}
public func togglePlayState() {
AssertIsOnMainThread()
guard let delegate else { return }
if delegate.audioPlaybackState == .playing {
pause()
} else {
play()
}
}
public func setCurrentTime(_ currentTime: TimeInterval) {
setupAudioPlayer()
guard let audioPlayer else {
owsFailDebug("audioPlayer == nil")
return
}
audioPlayer.currentTime = currentTime
delegate?.setAudioProgress(audioPlayer.currentTime, duration: audioPlayer.duration, playbackRate: playbackRate)
updateNowPlayingInfo()
}
// MARK: -
@objc
private func applicationDidEnterBackground() {
guard !supportsBackgroundPlayback else { return }
stop()
}
private var supportsBackgroundPlayback: Bool {
audioActivity.supportsBackgroundPlayback
}
private var supportsBackgroundPlaybackControls: Bool {
supportsBackgroundPlayback && !audioActivity.backgroundPlaybackName.isEmptyOrNil
}
private func updateNowPlayingInfo() {
// Only update the now playing info if the activity supports background playback
guard supportsBackgroundPlaybackControls else { return }
guard
let audioPlayer,
let backgroundPlaybackName = audioActivity.backgroundPlaybackName, !backgroundPlaybackName.isEmpty
else {
return
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: backgroundPlaybackName,
MPMediaItemPropertyPlaybackDuration: audioPlayer.duration,
MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer.currentTime
]
}
private func setupRemoteCommandCenter() {
guard supportsBackgroundPlaybackControls else { return }
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
commandCenter.changePlaybackPositionCommand.isEnabled = true
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
guard let changePlaybackPositionCommandEvent = event as? MPChangePlaybackPositionCommandEvent else {
owsFailDebug("event is not MPChangePlaybackPositionCommandEvent")
return .commandFailed
}
self?.setCurrentTime(changePlaybackPositionCommandEvent.positionTime)
return .success
}
updateNowPlayingInfo()
}
private func teardownRemoteCommandCenter() {
// If there's nothing left that wants background playback, disable lockscreen / control center controls
guard !audioSession.wantsBackgroundPlayback else { return }
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = false
commandCenter.pauseCommand.isEnabled = false
commandCenter.changePlaybackPositionCommand.isEnabled = false
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
// MARK: Events
@objc
private func audioPlayerUpdated(timer: Timer) {
AssertIsOnMainThread()
owsAssertDebug(audioPlayerPoller != nil)
guard let audioPlayer else {
owsFailDebug("audioPlayer == nil")
return
}
delegate?.setAudioProgress(audioPlayer.currentTime, duration: audioPlayer.duration, playbackRate: playbackRate)
}
}
extension AudioPlayer: AVAudioPlayerDelegate {
public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
AssertIsOnMainThread()
stop()
delegate?.audioPlayerDidFinish()
}
}