Files
letro-ios/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift
Mauro Romito 5e5fb19f42 pr suggestions
2026-02-19 22:40:31 +01:00

317 lines
11 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-2025 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 Combine
import Foundation
import MediaPlayer
import UIKit
enum AudioPlayerPlaybackState {
case loading
case readyToPlay
case playing
case stopped
case error
}
enum AudioPlayerStateIdentifier {
case timelineItemIdentifier(TimelineItemIdentifier)
case recorderPreview
}
@MainActor
class AudioPlayerState: ObservableObject, Identifiable {
let id: AudioPlayerStateIdentifier
let title: String
private(set) var duration: Double
let waveform: EstimatedWaveform
@Published private(set) var progress: Double
@Published private(set) var playbackState: AudioPlayerPlaybackState
/// It's similar to `playbackState`, with the a difference: `.loading`
/// updates are delayed by a fixed amount of time
@Published private(set) var playerButtonPlaybackState: AudioPlayerPlaybackState
@Published private(set) var playbackSpeed: AudioPlaybackSpeed
private weak var audioPlayer: AudioPlayerProtocol?
private var audioPlayerSubscription: AnyCancellable?
private var playbackStateSubscription: AnyCancellable?
private var displayLink: CADisplayLink?
/// The file url that the last player attached to this object has loaded.
/// The file url persists even if the AudioPlayer will be detached later.
private(set) var fileURL: URL?
var showProgressIndicator: Bool {
progress > 0
}
var isAttached: Bool {
audioPlayer != nil
}
var isPublishingProgress: Bool {
displayLink != nil
}
init(id: AudioPlayerStateIdentifier, title: String,
duration: Double,
waveform: EstimatedWaveform? = nil,
progress: Double = 0.0,
playbackSpeed: AudioPlaybackSpeed = .default,
playbackSpeedPublisher: AnyPublisher<AudioPlaybackSpeed, Never>? = nil) {
self.id = id
self.title = title
self.duration = duration
self.waveform = waveform ?? EstimatedWaveform(data: [])
self.progress = progress
self.playbackSpeed = playbackSpeed
playbackState = .stopped
playerButtonPlaybackState = .stopped
playbackSpeedPublisher?.assign(to: &$playbackSpeed)
setupPlaybackStateSubscription()
}
deinit {
displayLink?.invalidate()
displayLink = nil
}
func updateState(progress: Double) async {
let progress = max(0.0, min(progress, 1.0))
self.progress = progress
if let audioPlayer {
var shouldResumeProgressPublishing = false
if audioPlayer.state == .playing {
shouldResumeProgressPublishing = true
stopPublishProgress()
}
await audioPlayer.seek(to: progress)
if shouldResumeProgressPublishing, audioPlayer.state == .playing {
startPublishProgress()
}
}
}
func attachAudioPlayer(_ audioPlayer: AudioPlayerProtocol) {
if self.audioPlayer != nil {
detachAudioPlayer()
}
playbackState = .loading
self.audioPlayer = audioPlayer
subscribeToAudioPlayer(audioPlayer: audioPlayer)
setPlaybackSpeed(playbackSpeed)
}
func detachAudioPlayer() {
audioPlayer?.stop()
stopPublishProgress()
audioPlayerSubscription = nil
audioPlayer = nil
playbackState = .stopped
}
func reportError() {
playbackState = .error
}
func setPlaybackSpeed(_ speed: AudioPlaybackSpeed) {
playbackSpeed = speed
audioPlayer?.setPlaybackSpeed(speed.rawValue)
}
// MARK: - Private
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
audioPlayerSubscription = audioPlayer.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else {
return
}
Task {
await self.handleAudioPlayerAction(action)
}
}
}
private func handleAudioPlayerAction(_ action: AudioPlayerAction) async {
switch action {
case .didStartLoading:
playbackState = .loading
case .didFinishLoading:
if let audioPlayerDuration = audioPlayer?.duration, audioPlayerDuration != duration {
MXLog.info("updating duration: \(duration) -> \(audioPlayerDuration)")
duration = audioPlayerDuration
}
fileURL = audioPlayer?.playbackURL
playbackState = .readyToPlay
case .didStartPlaying:
if let audioPlayer {
await restoreAudioPlayerState(audioPlayer: audioPlayer)
}
startPublishProgress()
playbackState = .playing
setUpRemoteCommandCenter()
case .didPausePlaying:
stopPublishProgress()
playbackState = .stopped
case .didStopPlaying:
playbackState = .stopped
stopPublishProgress()
tearDownRemoteCommandCenter()
case .didFinishPlaying:
playbackState = .stopped
progress = 0.0
stopPublishProgress()
tearDownRemoteCommandCenter()
case .didFailWithError:
stopPublishProgress()
playbackState = .error
}
}
private func startPublishProgress() {
if displayLink != nil {
stopPublishProgress()
}
displayLink = CADisplayLink(target: self, selector: #selector(updateProgress))
displayLink?.preferredFrameRateRange = .init(minimum: 10, maximum: 20)
displayLink?.add(to: .current, forMode: .common)
}
// periphery:ignore:parameters displayLink - required for objc selector
@objc private func updateProgress(displayLink: CADisplayLink) {
if let currentTime = audioPlayer?.currentTime, duration > 0 {
progress = currentTime / duration
}
updateNowPlayingInfoCenter()
}
private func stopPublishProgress() {
displayLink?.invalidate()
displayLink = nil
}
private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async {
await audioPlayer.seek(to: progress)
}
private func setupPlaybackStateSubscription() {
playbackStateSubscription = $playbackState
.map { state in
switch state {
case .loading:
return Just(state)
.delay(for: .seconds(2), scheduler: RunLoop.main)
.eraseToAnyPublisher()
case .playing, .stopped, .error, .readyToPlay:
return Just(state)
.eraseToAnyPublisher()
}
}
.switchToLatest()
.removeDuplicates()
.weakAssign(to: \.playerButtonPlaybackState, on: self)
}
private func setUpRemoteCommandCenter() {
UIApplication.shared.beginReceivingRemoteControlEvents()
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = true
commandCenter.playCommand.removeTarget(nil)
commandCenter.playCommand.addTarget { [weak self] _ in
guard let audioPlayer = self?.audioPlayer else {
return MPRemoteCommandHandlerStatus.commandFailed
}
audioPlayer.play()
return MPRemoteCommandHandlerStatus.success
}
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.pauseCommand.addTarget { [weak self] _ in
guard let audioPlayer = self?.audioPlayer else {
return MPRemoteCommandHandlerStatus.commandFailed
}
audioPlayer.pause()
return MPRemoteCommandHandlerStatus.success
}
commandCenter.skipForwardCommand.isEnabled = true
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipForwardCommand.addTarget { [weak self] event in
guard let audioPlayer = self?.audioPlayer, let skipEvent = event as? MPSkipIntervalCommandEvent else {
return MPRemoteCommandHandlerStatus.commandFailed
}
Task {
await audioPlayer.seek(to: audioPlayer.currentTime + skipEvent.interval)
}
return MPRemoteCommandHandlerStatus.success
}
commandCenter.skipBackwardCommand.isEnabled = true
commandCenter.skipBackwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
guard let audioPlayer = self?.audioPlayer, let skipEvent = event as? MPSkipIntervalCommandEvent else {
return MPRemoteCommandHandlerStatus.commandFailed
}
Task {
await audioPlayer.seek(to: audioPlayer.currentTime - skipEvent.interval)
}
return MPRemoteCommandHandlerStatus.success
}
}
private func tearDownRemoteCommandCenter() {
UIApplication.shared.endReceivingRemoteControlEvents()
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
nowPlayingInfoCenter.nowPlayingInfo = nil
nowPlayingInfoCenter.playbackState = .stopped
let commandCenter = MPRemoteCommandCenter.shared()
commandCenter.playCommand.isEnabled = false
commandCenter.playCommand.removeTarget(nil)
commandCenter.pauseCommand.isEnabled = false
commandCenter.pauseCommand.removeTarget(nil)
commandCenter.skipForwardCommand.isEnabled = false
commandCenter.skipForwardCommand.removeTarget(nil)
commandCenter.skipBackwardCommand.isEnabled = false
commandCenter.skipBackwardCommand.removeTarget(nil)
}
private func updateNowPlayingInfoCenter() {
guard let audioPlayer else {
return
}
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: title,
MPMediaItemPropertyPlaybackDuration: audioPlayer.duration as Any,
MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer.currentTime as Any]
}
}
extension AudioPlayerState: Equatable {
nonisolated static func == (lhs: AudioPlayerState, rhs: AudioPlayerState) -> Bool {
lhs.id == rhs.id
}
}