mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 04:18:31 +00:00
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:
@@ -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)
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user