Add deferred spinner for voice messages (#2018)

* Add  delayedLoaderPlaybackState

* Add more tests

* Fix Xcode 14 build error

* Refactor  delayedLoaderPlaybackState -> playerButtonPlaybackState

* Fix runtime issue
This commit is contained in:
Alfonso Grillo
2023-11-03 11:39:22 +01:00
committed by GitHub
parent b10d534c94
commit 530985e674
6 changed files with 75 additions and 10 deletions
@@ -88,13 +88,13 @@ private struct VoiceMessageButtonStyle: ButtonStyle {
} }
extension VoiceMessageButton.State { extension VoiceMessageButton.State {
init(state: AudioPlayerPlaybackState) { init(_ state: AudioPlayerPlaybackState) {
switch state { switch state {
case .loading: case .loading:
self = .loading self = .loading
case .playing: case .playing:
self = .playing self = .playing
default: case .stopped, .error, .readyToPlay:
self = .paused self = .paused
} }
} }
@@ -43,7 +43,7 @@ struct VoiceMessagePreviewComposer: View {
var body: some View { var body: some View {
HStack { HStack {
HStack { HStack {
VoiceMessageButton(state: .init(state: playerState.playbackState), VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState),
size: .small, size: .small,
action: onPlayPause) action: onPlayPause)
Text(timeLabelContent) Text(timeLabelContent)
@@ -37,11 +37,15 @@ class AudioPlayerState: ObservableObject, Identifiable {
let duration: Double let duration: Double
let waveform: EstimatedWaveform let waveform: EstimatedWaveform
@Published private(set) var playbackState: AudioPlayerPlaybackState @Published private(set) var playbackState: AudioPlayerPlaybackState
/// It's similar to `playbackState`, with the a difference: `.loading`
/// updates are delayed by a fixed amount of time
@Published private(set) var playerButtonPlaybackState: AudioPlayerPlaybackState
@Published private(set) var progress: Double @Published private(set) var progress: Double
@Published private(set) var showProgressIndicator: Bool @Published private(set) var showProgressIndicator: Bool
private weak var audioPlayer: AudioPlayerProtocol? private weak var audioPlayer: AudioPlayerProtocol?
private var cancellables: Set<AnyCancellable> = [] private var audioPlayerSubscription: AnyCancellable?
private var playbackStateSubscription: AnyCancellable?
private var displayLink: CADisplayLink? private var displayLink: CADisplayLink?
/// The file url that the last player attached to this object has loaded. /// The file url that the last player attached to this object has loaded.
@@ -63,6 +67,8 @@ class AudioPlayerState: ObservableObject, Identifiable {
self.progress = progress self.progress = progress
showProgressIndicator = false showProgressIndicator = false
playbackState = .stopped playbackState = .stopped
playerButtonPlaybackState = .stopped
setupPlaybackStateSubscription()
} }
deinit { deinit {
@@ -99,7 +105,7 @@ class AudioPlayerState: ObservableObject, Identifiable {
func detachAudioPlayer() { func detachAudioPlayer() {
audioPlayer?.stop() audioPlayer?.stop()
stopPublishProgress() stopPublishProgress()
cancellables = [] audioPlayerSubscription = nil
audioPlayer = nil audioPlayer = nil
playbackState = .stopped playbackState = .stopped
showProgressIndicator = false showProgressIndicator = false
@@ -112,7 +118,7 @@ class AudioPlayerState: ObservableObject, Identifiable {
// MARK: - Private // MARK: - Private
private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) { private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) {
audioPlayer.actions audioPlayerSubscription = audioPlayer.actions
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] action in .sink { [weak self] action in
guard let self else { guard let self else {
@@ -122,7 +128,6 @@ class AudioPlayerState: ObservableObject, Identifiable {
await self.handleAudioPlayerAction(action) await self.handleAudioPlayerAction(action)
} }
} }
.store(in: &cancellables)
} }
private func handleAudioPlayerAction(_ action: AudioPlayerAction) async { private func handleAudioPlayerAction(_ action: AudioPlayerAction) async {
@@ -175,6 +180,24 @@ class AudioPlayerState: ObservableObject, Identifiable {
private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async { private func restoreAudioPlayerState(audioPlayer: AudioPlayerProtocol) async {
await audioPlayer.seek(to: progress) await audioPlayer.seek(to: progress)
} }
private func setupPlaybackStateSubscription() {
playbackStateSubscription = $playbackState
.map { state in
switch state {
case .loading:
return Just(state)
.delay(for: .seconds(2), scheduler: RunLoop.main)
.eraseToAnyPublisher()
case .playing, .stopped, .error, .readyToPlay:
return Just(state)
.eraseToAnyPublisher()
}
}
.switchToLatest()
.removeDuplicates()
.weakAssign(to: \.playerButtonPlaybackState, on: self)
}
} }
extension AudioPlayerState: Equatable { extension AudioPlayerState: Equatable {
@@ -409,8 +409,10 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else { guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
continue continue
} }
playerState.detachAudioPlayer() Task { @MainActor in
mediaPlayerProvider.unregister(audioPlayerState: playerState) playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
} }
newTimelineItems.append(timelineItem) newTimelineItems.append(timelineItem)
@@ -43,7 +43,7 @@ struct VoiceMessageRoomPlaybackView: View {
var body: some View { var body: some View {
HStack { HStack {
HStack { HStack {
VoiceMessageButton(state: .init(state: playerState.playbackState), VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState),
size: .medium, size: .medium,
action: onPlayPause) action: onPlayPause)
Text(timeLabelContent) Text(timeLabelContent)
@@ -69,6 +69,46 @@ class AudioPlayerStateTests: XCTestCase {
XCTAssertFalse(audioPlayerState.showProgressIndicator) XCTAssertFalse(audioPlayerState.showProgressIndicator)
} }
func testDelayedState() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
XCTAssert(audioPlayerState.isAttached)
XCTAssertEqual(audioPlayerState.playbackState, .loading)
XCTAssertEqual(audioPlayerState.playerButtonPlaybackState, .stopped)
let deferred = deferFulfillment(audioPlayerState.$playerButtonPlaybackState) { output in
switch output {
case .loading:
return true
default:
return false
}
}
try await deferred.fulfill()
XCTAssertEqual(audioPlayerState.playerButtonPlaybackState, .loading)
}
func testOtherActionsAreNotDelayed() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
XCTAssertEqual(audioPlayerState.playbackState, .loading)
XCTAssertEqual(audioPlayerState.playerButtonPlaybackState, .stopped)
let deferred = deferFulfillment(audioPlayerState.$playerButtonPlaybackState) { output in
switch output {
case .playing:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStartPlaying)
try await deferred.fulfill()
XCTAssertEqual(audioPlayerState.playbackState, .playing)
XCTAssertEqual(audioPlayerState.playerButtonPlaybackState, .playing)
}
func testReportError() async throws { func testReportError() async throws {
XCTAssertEqual(audioPlayerState.playbackState, .stopped) XCTAssertEqual(audioPlayerState.playbackState, .stopped)
audioPlayerState.reportError(AudioPlayerError.genericError) audioPlayerState.reportError(AudioPlayerError.genericError)