Autoplay the next voice message if it is the next timeline item. (#6089)

This commit is contained in:
Mauro
2026-09-01 18:00:14 +02:00
committed by GitHub
parent b4a36ad465
commit 86bc4d54b6
13 changed files with 463 additions and 16 deletions
@@ -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