TM-SGNL-iOS/Signal/ConversationView/CVAudioPlayback.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

423 lines
13 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Foundation
public import SignalServiceKit
public import SignalUI
protocol CVAudioPlayerListener {
func audioPlayerStateDidChange(attachmentId: Attachment.IDType)
func audioPlayerDidFinish(attachmentId: Attachment.IDType)
func audioPlayerDidMarkViewed(attachmentId: Attachment.IDType)
}
// MARK: -
// Tool for playing audio attachments and observing playback state.
//
// Responsibilities:
//
// * Ensure that no more than one audio attachment is playing at a time.
// * Ensure playback continuity.
// * This should work:
// * If cells are reloaded
// * Playback is manipulated in a subview like message details view
// * The cell is scrolled offscreen and unloaded.
// * etc.
// * Ensure thread safety.
public class CVAudioPlayer: NSObject {
// The currently playing audio, if any.
private var _audioPlayback: CVAudioPlayback?
private var audioPlayback: CVAudioPlayback? {
get {
AssertIsOnMainThread()
return _audioPlayback
}
set {
AssertIsOnMainThread()
_audioPlayback = newValue
}
}
private var autoplayAttachmentId: Attachment.IDType?
// Views need to update to reflect playback progress, state changes.
private var listeners = WeakArray<CVAudioPlayerListener>()
func addListener(_ listener: CVAudioPlayerListener) {
AssertIsOnMainThread()
listeners.append(listener)
listeners.cullExpired()
}
// This ensures playback continuity. If users switches between
// playing back different audio attachment, each should resume
// where it left off.
//
// Playback progress should be continuous even if the corresponding
// cells are reloaded or scrolled offscreen and unloaded.
public typealias AttachmentId = Attachment.IDType
private var progressCache = LRUCache<AttachmentId, TimeInterval>(maxSize: 512)
// Playback rate cached by thread id, _not_ attachment ID. Playback rate is preserved
// across all audio attachments in a given thread.
//
// Note that the source of truth for playback rate is the ThreadAssociatedData db table,
// but we keep this in-memory cache around for autoplay purposes.
public typealias ThreadId = String
private var playbackRateCache = LRUCache<ThreadId, Float>(maxSize: 512)
// If nil, autoplay is enabled. Otherwise, this closure returns whether to play the next audio attachement. It's
// called when the current attachment finishes playing.
var shouldAutoplayNextAudioAttachment: (() -> Bool)?
public func audioPlaybackState(forAttachmentId attachmentId: Attachment.IDType) -> AudioPlaybackState {
AssertIsOnMainThread()
guard let audioPlayback = audioPlayback else {
return .stopped
}
guard audioPlayback.attachmentId == attachmentId else {
return .stopped
}
return audioPlayback.audioPlaybackState
}
private func ensurePlayback(for attachment: AudioAttachment, forAutoplay: Bool = false) -> CVAudioPlayback? {
AssertIsOnMainThread()
guard let attachmentId = attachment.attachmentStream?.attachmentStream.id else {
return nil
}
autoplayAttachmentId = forAutoplay ? attachmentId : nil
if let audioPlayback = self.audioPlayback,
audioPlayback.attachmentId == attachmentId {
return audioPlayback
}
guard let audioPlayback = CVAudioPlayback(attachment: attachment) else {
owsFailDebug("Could not play audio attachment.")
return nil
}
// Restore playback continuity.
if let progress = progressCache[attachmentId] {
audioPlayback.setProgress(progress)
}
if
let uniqueThreadId = audioPlayback.uniqueThreadId,
let playbackRate = playbackRateCache[uniqueThreadId]
{
audioPlayback.setPlaybackRate(playbackRate)
} else {
audioPlayback.setPlaybackRate(1)
}
audioPlayback.delegate = self
let oldAudioPlayback = self.audioPlayback
self.audioPlayback = audioPlayback
// Let the existing player know its state has changed.
if let oldId = oldAudioPlayback?.attachmentId {
for listener in listeners.elements {
listener.audioPlayerStateDidChange(attachmentId: oldId)
}
}
return audioPlayback
}
public func togglePlayState(forAudioAttachment audioAttachment: AudioAttachment) {
AssertIsOnMainThread()
guard let audioPlayback = ensurePlayback(for: audioAttachment) else {
owsFailDebug("Could not play audio attachment.")
return
}
if audioAttachment.markOwningMessageAsViewed() {
for listener in listeners.elements {
listener.audioPlayerDidMarkViewed(attachmentId: audioPlayback.attachmentId)
}
}
audioPlayback.togglePlayState()
}
public var audioPlaybackState: AudioPlaybackState = .stopped
private var soundPlayer: AudioPlayer?
private var soundComplete: (() -> Void)?
private func playStandardSound(_ sound: StandardSound, completion: (() -> Void)? = nil) {
AssertIsOnMainThread()
if let soundPlayer {
soundPlayer.stop()
soundComplete?()
}
soundPlayer = Sounds.audioPlayer(forSound: .standard(sound), audioBehavior: .audioMessagePlayback)
soundPlayer?.delegate = self
soundPlayer?.play()
soundComplete = completion
}
public func autoplayNextAudioAttachmentIfNeeded(_ audioAttachment: AudioAttachment?) {
AssertIsOnMainThread()
guard shouldAutoplayNextAudioAttachment?() ?? true else {
playStandardSound(.endLastTrack)
return
}
guard let audioAttachment = audioAttachment, let attachmentStream = audioAttachment.attachmentStream else {
if audioPlayback?.attachmentId == autoplayAttachmentId {
// Play a tone indicating the last track completed.
playStandardSound(.endLastTrack)
}
return
}
guard let audioPlayback = ensurePlayback(for: audioAttachment, forAutoplay: true) else {
owsFailDebug("Could not play audio attachment.")
return
}
// Play a tone indicating the next track is starting.
playStandardSound(.beginNextTrack) { [weak self] in
// Make sure the user didn't start another attachment while the tone was playing.
guard self?.autoplayAttachmentId == attachmentStream.attachmentStream.id else { return }
guard self?.audioPlayback?.attachmentId == attachmentStream.attachmentStream.id else { return }
guard audioPlayback.audioPlaybackState != .playing else { return }
if audioAttachment.markOwningMessageAsViewed() {
for listener in self?.listeners.elements ?? [] {
listener.audioPlayerDidMarkViewed(attachmentId: audioPlayback.attachmentId)
}
}
audioPlayback.setProgress(0)
audioPlayback.togglePlayState()
}
}
public func setPlaybackProgress(
progress: TimeInterval,
forAttachmentStream attachmentStream: AttachmentStream
) {
AssertIsOnMainThread()
progressCache[attachmentStream.id] = progress
if let audioPlayback = audioPlayback, audioPlayback.attachmentId == attachmentStream.id {
audioPlayback.setProgress(progress)
}
}
public func playbackProgress(forAttachmentStream attachmentStream: AttachmentStream) -> TimeInterval {
AssertIsOnMainThread()
let attachmentId = attachmentStream.id
return progressCache[attachmentId] ?? 0
}
public func setPlaybackRate(
_ rate: Float,
forThreadUniqueId threadId: ThreadId
) {
AssertIsOnMainThread()
// Cache it so if this gets called before playback begins and
// we create the audioPlayback instance later, we can set the rate on it.
playbackRateCache[threadId] = rate
if
let audioPlayback = audioPlayback,
audioPlayback.uniqueThreadId == threadId
{
audioPlayback.setPlaybackRate(rate)
}
}
public func stopAll() {
guard let audioPlayback = self.audioPlayback else {
return
}
audioPlayback.stop()
self.audioPlayback = nil
}
}
extension CVAudioPlayer: AudioPlayerDelegate {
public func setAudioProgress(_ progress: TimeInterval, duration: TimeInterval, playbackRate: Float) {}
public func audioPlayerDidFinish() {
DispatchMainThreadSafe { [weak self] in
self?.soundPlayer?.stop()
self?.soundPlayer = nil
self?.soundComplete?()
self?.soundComplete = nil
}
}
}
// MARK: -
extension CVAudioPlayer: CVAudioPlaybackDelegate {
fileprivate func audioPlaybackStateDidChange(_ audioPlayback: CVAudioPlayback) {
AssertIsOnMainThread()
switch audioPlayback.audioPlaybackState {
case .playing:
if audioPlayback != self.audioPlayback { audioPlayback.togglePlayState() }
progressCache[audioPlayback.attachmentId] = audioPlayback.progress
case .stopped:
progressCache[audioPlayback.attachmentId] = 0
case .paused:
break
}
for listener in listeners.elements {
listener.audioPlayerStateDidChange(attachmentId: audioPlayback.attachmentId)
}
}
fileprivate func audioPlaybackDidFinish(_ audioPlayback: CVAudioPlayback) {
AssertIsOnMainThread()
progressCache[audioPlayback.attachmentId] = 0
for listener in listeners.elements {
listener.audioPlayerDidFinish(attachmentId: audioPlayback.attachmentId)
}
}
}
// MARK: -
private protocol CVAudioPlaybackDelegate: AnyObject {
func audioPlaybackStateDidChange(_ audioPlayback: CVAudioPlayback)
func audioPlaybackDidFinish(_ audioPlayback: CVAudioPlayback)
}
// MARK: -
// Used for playback of a given audio attachment.
//
// TODO: Should we combine this with AudioPlayer?
private class CVAudioPlayback: NSObject, AudioPlayerDelegate {
fileprivate weak var delegate: CVAudioPlaybackDelegate?
fileprivate let uniqueThreadId: String?
fileprivate let attachmentId: Attachment.IDType
private let audioPlayer: AudioPlayer
private let _playbackState = AtomicValue<AudioPlaybackState>(AudioPlaybackState.stopped, lock: .sharedGlobal)
public var audioPlaybackState: AudioPlaybackState {
get {
AssertIsOnMainThread()
return _playbackState.get()
}
set {
AssertIsOnMainThread()
_playbackState.set(newValue)
}
}
private struct AudioTiming {
let progress: TimeInterval
let duration: TimeInterval
let playbackRate: Float
static var unknown: AudioTiming {
AudioTiming(progress: 0, duration: 0, playbackRate: 1)
}
}
private let audioTiming = AtomicValue<AudioTiming>(AudioTiming.unknown, lock: .sharedGlobal)
public var progress: TimeInterval {
AssertIsOnMainThread()
return audioTiming.get().progress
}
public var duration: TimeInterval {
AssertIsOnMainThread()
return audioTiming.get().duration
}
public var playbackRate: Float {
AssertIsOnMainThread()
return audioTiming.get().playbackRate
}
public func setAudioProgress(_ progress: TimeInterval, duration: TimeInterval, playbackRate: Float) {
AssertIsOnMainThread()
audioTiming.set(AudioTiming(progress: progress, duration: duration, playbackRate: playbackRate))
delegate?.audioPlaybackStateDidChange(self)
}
public func audioPlayerDidFinish() {
AssertIsOnMainThread()
// Clear progress, preserve duration and playback rate.
audioTiming.set(AudioTiming(progress: 0, duration: duration, playbackRate: playbackRate))
delegate?.audioPlaybackDidFinish(self)
}
public init?(attachment: AudioAttachment) {
AssertIsOnMainThread()
guard let attachmentStream = attachment.attachmentStream else {
owsFailDebug("missing audio attachment stream \(attachment)")
return nil
}
self.attachmentId = attachmentStream.attachmentStream.id
audioPlayer = AudioPlayer(attachment: attachmentStream.attachmentStream, audioBehavior: .audioMessagePlayback)
uniqueThreadId = attachment.owningMessage?.uniqueThreadId
super.init()
audioPlayer.delegate = self
audioPlayer.setupAudioPlayer()
}
deinit {
stop()
}
fileprivate func stop() {
AssertIsOnMainThread()
audioPlayer.stop()
}
public func togglePlayState() {
AssertIsOnMainThread()
audioPlayer.togglePlayState()
}
public func setProgress(_ time: TimeInterval) {
AssertIsOnMainThread()
audioPlayer.setCurrentTime(time)
}
public func setPlaybackRate(_ rate: Float) {
AssertIsOnMainThread()
audioPlayer.playbackRate = rate
}
}