* New LICENSE-COMMERCIAL file * Apply dual licenses: AGPL + Element Commercial to file headers * Update README with dual licensing
248 lines
7.3 KiB
Swift
248 lines
7.3 KiB
Swift
//
|
|
// Copyright 2023, 2024 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import AVFoundation
|
|
import Combine
|
|
import Foundation
|
|
import UIKit
|
|
|
|
private enum InternalAudioPlayerState {
|
|
case none
|
|
case loading
|
|
case readyToPlay
|
|
case playing
|
|
case paused
|
|
case stopped
|
|
case finishedPlaying
|
|
case error(Error)
|
|
}
|
|
|
|
class AudioPlayer: NSObject, AudioPlayerProtocol {
|
|
var sourceURL: URL?
|
|
|
|
private var playerItem: AVPlayerItem?
|
|
private var internalAudioPlayer: AVQueuePlayer?
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private let actionsSubject: PassthroughSubject<AudioPlayerAction, Never> = .init()
|
|
var actions: AnyPublisher<AudioPlayerAction, Never> {
|
|
actionsSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
private var internalState = InternalAudioPlayerState.none
|
|
|
|
private var statusObserver: NSKeyValueObservation?
|
|
private var rateObserver: NSKeyValueObservation?
|
|
private var autoplay = false
|
|
|
|
private let audioSession = AVAudioSession.sharedInstance()
|
|
|
|
// periphery:ignore - when set to nil is automatically cancelled
|
|
@CancellableTask private var releaseAudioSessionTask: Task<Void, Never>?
|
|
|
|
private let releaseAudioSessionTimeoutInterval = 5.0
|
|
|
|
private(set) var playbackURL: URL?
|
|
|
|
private var deinitInProgress = false
|
|
|
|
var duration: TimeInterval {
|
|
abs(CMTimeGetSeconds(internalAudioPlayer?.currentItem?.duration ?? .zero))
|
|
}
|
|
|
|
var currentTime: TimeInterval {
|
|
let currentTime = abs(CMTimeGetSeconds(internalAudioPlayer?.currentTime() ?? .zero))
|
|
return currentTime.isFinite ? currentTime : .zero
|
|
}
|
|
|
|
var state: MediaPlayerState {
|
|
if case .loading = internalState {
|
|
return .loading
|
|
}
|
|
if case .stopped = internalState {
|
|
return .stopped
|
|
}
|
|
if case .playing = internalState {
|
|
return .playing
|
|
}
|
|
if case .paused = internalState {
|
|
return .paused
|
|
}
|
|
if case .error = internalState {
|
|
return .error
|
|
}
|
|
return .stopped
|
|
}
|
|
|
|
private var isStopped = true
|
|
|
|
deinit {
|
|
deinitInProgress = true
|
|
stop()
|
|
unloadContent()
|
|
}
|
|
|
|
func load(sourceURL: URL, playbackURL: URL, autoplay: Bool) {
|
|
unloadContent()
|
|
setInternalState(.loading)
|
|
self.sourceURL = sourceURL
|
|
self.playbackURL = playbackURL
|
|
self.autoplay = autoplay
|
|
playerItem = AVPlayerItem(url: playbackURL)
|
|
internalAudioPlayer = AVQueuePlayer(playerItem: playerItem)
|
|
addObservers()
|
|
}
|
|
|
|
func reset() {
|
|
stop()
|
|
unloadContent()
|
|
}
|
|
|
|
func play() {
|
|
isStopped = false
|
|
setupAudioSession()
|
|
internalAudioPlayer?.play()
|
|
}
|
|
|
|
func pause() {
|
|
guard case .playing = internalState else { return }
|
|
internalAudioPlayer?.pause()
|
|
releaseAudioSession(after: releaseAudioSessionTimeoutInterval)
|
|
}
|
|
|
|
func stop() {
|
|
guard !isStopped else { return }
|
|
isStopped = true
|
|
internalAudioPlayer?.pause()
|
|
internalAudioPlayer?.seek(to: .zero)
|
|
releaseAudioSession(after: releaseAudioSessionTimeoutInterval)
|
|
}
|
|
|
|
func seek(to progress: Double) async {
|
|
guard let internalAudioPlayer else { return }
|
|
let time = progress * duration
|
|
await internalAudioPlayer.seek(to: CMTime(seconds: time, preferredTimescale: 60))
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func setupAudioSession() {
|
|
releaseAudioSessionTask = nil
|
|
do {
|
|
try audioSession.setCategory(.playback)
|
|
try audioSession.setActive(true)
|
|
} catch {
|
|
MXLog.error("Could not redirect audio playback to speakers.")
|
|
}
|
|
}
|
|
|
|
private func releaseAudioSession(after timeInterval: TimeInterval) {
|
|
guard !deinitInProgress else {
|
|
releaseAudioSession()
|
|
return
|
|
}
|
|
releaseAudioSessionTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(timeInterval))
|
|
guard !Task.isCancelled else { return }
|
|
|
|
self?.releaseAudioSession()
|
|
}
|
|
}
|
|
|
|
private func releaseAudioSession() {
|
|
releaseAudioSessionTask = nil
|
|
if audioSession.category == .playback, !audioSession.isOtherAudioPlaying {
|
|
MXLog.info("releasing audio session")
|
|
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
|
}
|
|
}
|
|
|
|
private func unloadContent() {
|
|
sourceURL = nil
|
|
playbackURL = nil
|
|
internalAudioPlayer?.replaceCurrentItem(with: nil)
|
|
internalAudioPlayer = nil
|
|
playerItem = nil
|
|
removeObservers()
|
|
}
|
|
|
|
private func addObservers() {
|
|
guard let internalAudioPlayer, let playerItem else {
|
|
return
|
|
}
|
|
|
|
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] _, _ in
|
|
guard let self else { return }
|
|
|
|
switch playerItem.status {
|
|
case .failed:
|
|
setInternalState(.error(playerItem.error ?? AudioPlayerError.genericError))
|
|
case .readyToPlay:
|
|
guard state == .loading else { return }
|
|
setInternalState(.readyToPlay)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] _, _ in
|
|
guard let self else { return }
|
|
|
|
if internalAudioPlayer.rate == 0 {
|
|
if isStopped {
|
|
setInternalState(.stopped)
|
|
} else {
|
|
setInternalState(.paused)
|
|
}
|
|
} else {
|
|
setInternalState(.playing)
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime)
|
|
.sink { [weak self] _ in
|
|
guard let self else { return }
|
|
setInternalState(.finishedPlaying)
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
private func removeObservers() {
|
|
statusObserver?.invalidate()
|
|
rateObserver?.invalidate()
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
private func setInternalState(_ state: InternalAudioPlayerState) {
|
|
internalState = state
|
|
switch state {
|
|
case .none:
|
|
break
|
|
case .loading:
|
|
actionsSubject.send(.didStartLoading)
|
|
case .readyToPlay:
|
|
actionsSubject.send(.didFinishLoading)
|
|
if autoplay {
|
|
autoplay = false
|
|
play()
|
|
}
|
|
case .playing:
|
|
actionsSubject.send(.didStartPlaying)
|
|
case .paused:
|
|
actionsSubject.send(.didPausePlaying)
|
|
case .stopped:
|
|
actionsSubject.send(.didStopPlaying)
|
|
case .finishedPlaying:
|
|
actionsSubject.send(.didFinishPlaying)
|
|
unloadContent()
|
|
case .error(let error):
|
|
MXLog.error("audio player did fail. \(error)")
|
|
actionsSubject.send(.didFailWithError(error: error))
|
|
}
|
|
}
|
|
}
|