// // 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 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() private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { 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? private let releaseAudioSessionTimeoutInterval = 5.0 private(set) var playbackURL: URL? private(set) var playbackSpeed: Float = 1.0 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 isolated 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?.rate = playbackSpeed } 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)) } func setPlaybackSpeed(_ speed: Float) { playbackSpeed = speed if state == .playing { internalAudioPlayer?.rate = speed } } // 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] item, _ in // KVO fires on an arbitrary thread, hop to the main actor with the values we need. let status = item.status let error = item.error Task { @MainActor [weak self] in guard let self else { return } switch status { case .failed: setInternalState(.error(error ?? AudioPlayerError.genericError)) case .readyToPlay: guard state == .loading else { return } setInternalState(.readyToPlay) default: break } } } rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in // KVO fires on an arbitrary thread, hop to the main actor with the values we need. let rate = player.rate Task { @MainActor [weak self] in guard let self else { return } if 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)) } } }