Files
Florian 5f724941ee Voice message: variable play back speed (#5121)
* Implement voice message variable playback speed

* Move playback speed string to Untranslated

https://github.com/element-hq/element-x-ios/pull/5121#discussion_r2822631371

* Address review feedback for voice message playback speed

- Persist voice message playback speed as an enum in AppSettings instead of storing an index.
- Update playback speed cycling to derive from enum allCases and safely fall back to `.default` if the stored value cannot be resolved.
- Apply runtime speed updates in AudioPlayer only when the player is in the `.playing` state.
- Keep MediaPlayerProviderTests formatting/indentation style intact while retaining mock playback speed setup.
- Regenerate preview snapshots for:
  - PlaybackSpeedButton
  - VoiceMessageRoomPlaybackView
  - VoiceMessageRoomTimelineView

* Move VoiceMessagePlaybackSpeed outside AppSettings, remove speedRatio

* Stabilize PlaybackSpeedButton width

* Sync voice-message speed label

- Add voiceMessagePlaybackSpeed to TimelineViewState and bind it from appSettings.$voiceMessagePlaybackSpeed.
- Pass that timeline-level speed into VoiceMessageRoomPlaybackView and use it for PlaybackSpeedButton, so labels update consistently across items.
- Use @EnvironmentObject in VoiceMessageRoomTimelineContent so the view re-renders when timeline context state changes.
- In WaveformInteractionModifier, add .allowsHitTesting(showCursor) to the cursor interaction view so hidden pre-playback cursor hit area does not steal taps from the speed button.
2026-02-19 22:40:31 +01:00

257 lines
7.5 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 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(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
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] _, _ 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))
}
}
}