mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Autoplay the next voice message if it is the next timeline item. (#6089)
This commit is contained in:
@@ -8081,6 +8081,37 @@ nonisolated class MediaPlayerProviderMock: MediaPlayerProviderProtocol, @uncheck
|
||||
detachAllStatesExceptReceivedInvocationsLock.withLock { detachAllStatesExceptUnderlyingReceivedInvocations.append(exception) }
|
||||
await detachAllStatesExceptClosure?(exception)
|
||||
}
|
||||
//MARK: - play
|
||||
|
||||
private let playSoundEffectCallsCountLock = NSLock()
|
||||
private nonisolated(unsafe) var playSoundEffectUnderlyingCallsCount = 0
|
||||
var playSoundEffectCallsCount: Int {
|
||||
get { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount } }
|
||||
set { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount = newValue } }
|
||||
}
|
||||
var playSoundEffectCalled: Bool {
|
||||
return playSoundEffectCallsCount > 0
|
||||
}
|
||||
private let playSoundEffectReceivedSoundEffectLock = NSLock()
|
||||
private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedSoundEffect: SoundEffect?
|
||||
var playSoundEffectReceivedSoundEffect: SoundEffect? {
|
||||
get { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect } }
|
||||
set { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect = newValue } }
|
||||
}
|
||||
private let playSoundEffectReceivedInvocationsLock = NSLock()
|
||||
private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedInvocations: [SoundEffect] = []
|
||||
var playSoundEffectReceivedInvocations: [SoundEffect] {
|
||||
get { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations } }
|
||||
set { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations = newValue } }
|
||||
}
|
||||
nonisolated(unsafe) var playSoundEffectClosure: ((SoundEffect) -> Void)?
|
||||
|
||||
func play(soundEffect: SoundEffect) {
|
||||
playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount += 1 }
|
||||
playSoundEffectReceivedSoundEffect = soundEffect
|
||||
playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations.append(soundEffect) }
|
||||
playSoundEffectClosure?(soundEffect)
|
||||
}
|
||||
}
|
||||
nonisolated class MediaProviderMock: MediaProviderProtocol, @unchecked Sendable {
|
||||
|
||||
|
||||
@@ -64,4 +64,18 @@ nonisolated extension Array where Element == RoomTimelineItemProtocol {
|
||||
func firstUsingStableID(_ id: TimelineItemIdentifier) -> Element? {
|
||||
first { $0.id.uniqueID == id.uniqueID }
|
||||
}
|
||||
|
||||
/// The voice message that directly follows the item with the given ID, if there is one.
|
||||
///
|
||||
/// Decorations such as date separators and the read marker are ignored, but any other
|
||||
/// kind of item in between means that no voice message directly follows.
|
||||
func voiceMessageDirectlyFollowing(_ id: TimelineItemIdentifier) -> VoiceMessageRoomTimelineItem? {
|
||||
guard let index = firstIndex(where: { $0.id.uniqueID == id.uniqueID }) else { return nil }
|
||||
|
||||
for item in self[index...].dropFirst() where !(item is DecorationTimelineItemProtocol) {
|
||||
return item as? VoiceMessageRoomTimelineItem
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,16 @@ nonisolated extension URL {
|
||||
return url
|
||||
}
|
||||
|
||||
/// The directory holding the sounds that iOS uses for its own interface.
|
||||
static let systemSoundsDirectory: URL = {
|
||||
let systemRoot = if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
|
||||
URL(filePath: simulatorRoot)
|
||||
} else {
|
||||
URL(filePath: "/")
|
||||
}
|
||||
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
|
||||
}()
|
||||
|
||||
var globalProxy: String? {
|
||||
guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else {
|
||||
MXLog.error("Failed retrieving proxy settings")
|
||||
|
||||
@@ -59,6 +59,15 @@ class TimelineInteractionHandler {
|
||||
|
||||
private var resumeVoiceMessagePlaybackAfterScrubbing = false
|
||||
|
||||
/// The voice message playback that was last asked for, and whether the player has begun it.
|
||||
private enum VoiceMessagePlayback {
|
||||
case requested(TimelineItemIdentifier)
|
||||
case playing(TimelineItemIdentifier)
|
||||
}
|
||||
|
||||
private var voiceMessagePlayback: VoiceMessagePlayback?
|
||||
private var audioPlayerActionsCancellable: AnyCancellable?
|
||||
|
||||
private var emojiPickerCancellable: AnyCancellable?
|
||||
|
||||
init(roomProxy: JoinedRoomProxyProtocol,
|
||||
@@ -449,8 +458,19 @@ class TimelineInteractionHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// Loading a new message tears the player down, which cancels any autoplay that was pending for the previous one.
|
||||
voiceMessagePlayback = .requested(itemID)
|
||||
|
||||
let audioPlayer = mediaPlayerProvider.player
|
||||
|
||||
// Observe the player lazily so that the mocks used by the previews and the UI tests don't need one.
|
||||
if audioPlayerActionsCancellable == nil {
|
||||
audioPlayerActionsCancellable = audioPlayer.actions
|
||||
.sink { [weak self] action in
|
||||
Task { await self?.handleAudioPlayerAction(action) }
|
||||
}
|
||||
}
|
||||
|
||||
// Stop any recording in progress
|
||||
if voiceMessageRecorder.isRecording {
|
||||
await voiceMessageRecorder.stopRecording()
|
||||
@@ -493,6 +513,36 @@ class TimelineInteractionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioPlayerAction(_ action: AudioPlayerAction) async {
|
||||
switch action {
|
||||
case .didStartPlaying:
|
||||
if case .requested(let itemID) = voiceMessagePlayback {
|
||||
voiceMessagePlayback = .playing(itemID)
|
||||
}
|
||||
case .didFinishPlaying:
|
||||
// Anything else means the player was torn down to play something different
|
||||
// rather than reaching the end of the message it had started.
|
||||
guard case .playing(let finishedItemID) = voiceMessagePlayback else { return }
|
||||
voiceMessagePlayback = nil
|
||||
await autoplayVoiceMessage(following: finishedItemID)
|
||||
case .didStartLoading, .didFinishLoading, .didPausePlaying, .didStopPlaying, .didFailWithError:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the voice message directly following the given one.
|
||||
private func autoplayVoiceMessage(following finishedItemID: TimelineItemIdentifier) async {
|
||||
// The playback may have been taken over by another player state, such as the recorder's preview.
|
||||
guard audioPlayerState(for: finishedItemID)?.isAttached == true,
|
||||
let nextVoiceMessage = timelineController.timelineItems.voiceMessageDirectlyFollowing(finishedItemID) else {
|
||||
return
|
||||
}
|
||||
|
||||
MXLog.info("Autoplaying the voice message following itemID \(finishedItemID)")
|
||||
mediaPlayerProvider.play(soundEffect: .tink)
|
||||
await playPauseAudio(for: nextVoiceMessage.id)
|
||||
}
|
||||
|
||||
func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async {
|
||||
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else {
|
||||
return
|
||||
|
||||
@@ -263,15 +263,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
|
||||
filename: "sound_01.caf")
|
||||
].sorted()
|
||||
|
||||
private static let systemLocation = {
|
||||
let systemRoot: URL
|
||||
if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] {
|
||||
systemRoot = URL(filePath: simulatorRoot)
|
||||
} else {
|
||||
systemRoot = URL(filePath: "/")
|
||||
}
|
||||
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
|
||||
}()
|
||||
private static let systemLocation = URL.systemSoundsDirectory
|
||||
|
||||
private static let bundledLocation: URL = {
|
||||
guard let url = Bundle.app.resourceURL else {
|
||||
|
||||
@@ -179,16 +179,19 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
}
|
||||
|
||||
private func addObservers() {
|
||||
guard let internalAudioPlayer, let playerItem else {
|
||||
// Deliberately not shadowing the properties, the observers compare against them to detect stale updates.
|
||||
guard let currentAudioPlayer = internalAudioPlayer, let currentItem = playerItem else {
|
||||
return
|
||||
}
|
||||
|
||||
statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] item, _ in
|
||||
statusObserver = currentItem.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 }
|
||||
// Ignore an update that was queued before the content was replaced, it would leave
|
||||
// the state out of sync with the item that is loaded now.
|
||||
guard let self, item === playerItem else { return }
|
||||
|
||||
switch status {
|
||||
case .failed:
|
||||
@@ -202,11 +205,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in
|
||||
rateObserver = currentAudioPlayer.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 }
|
||||
guard let self, player === internalAudioPlayer else { return }
|
||||
|
||||
if rate == 0 {
|
||||
if isStopped {
|
||||
@@ -220,7 +223,9 @@ class AudioPlayer: NSObject, AudioPlayerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime)
|
||||
NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime, object: currentItem)
|
||||
// The notification isn't posted on the main thread, unlike the state updates it triggers.
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
setInternalState(.finishedPlaying)
|
||||
|
||||
@@ -128,7 +128,6 @@ class AudioPlayerState: ObservableObject, Identifiable {
|
||||
|
||||
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
|
||||
audioPlayerSubscription = audioPlayer.actions
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] action in
|
||||
guard let self else {
|
||||
return
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
||||
private lazy var audioPlayer = AudioPlayer()
|
||||
private var audioPlayerStates: [String: AudioPlayerState] = [:]
|
||||
/// Held onto for the duration of the playback, an unowned player is silent.
|
||||
private var soundEffectPlayer: AVAudioPlayer?
|
||||
|
||||
var player: AudioPlayerProtocol {
|
||||
audioPlayer
|
||||
@@ -55,6 +58,17 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Doesn't configure an audio session, so that it can be used alongside any existing player or session.
|
||||
func play(soundEffect: SoundEffect) {
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: soundEffect.fileURL)
|
||||
soundEffectPlayer = player
|
||||
player.play()
|
||||
} catch {
|
||||
MXLog.error("Failed playing the sound effect: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? {
|
||||
|
||||
@@ -8,6 +8,17 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SoundEffect {
|
||||
/// A short, light blip.
|
||||
case tink
|
||||
|
||||
var fileURL: URL {
|
||||
switch self {
|
||||
case .tink: URL.systemSoundsDirectory.appending(component: "Tink.caf")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol MediaPlayerProviderProtocol {
|
||||
var player: AudioPlayerProtocol { get }
|
||||
|
||||
@@ -15,6 +26,10 @@ protocol MediaPlayerProviderProtocol {
|
||||
func register(audioPlayerState: AudioPlayerState)
|
||||
func unregister(audioPlayerState: AudioPlayerState)
|
||||
func detachAllStates(except exception: AudioPlayerState?) async
|
||||
|
||||
/// Plays a short sound on top of whatever is already playing. It goes through the app's own
|
||||
/// audio session, so unlike the sounds built into iOS it is heard in silent mode too.
|
||||
func play(soundEffect: SoundEffect)
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
|
||||
Reference in New Issue
Block a user