mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-23 18:12:44 +02:00
Support voice broadcast live playback (#7094)
This commit is contained in:
+1
-1
@@ -22,7 +22,7 @@ struct VoiceBroadcastPlaybackCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let room: MXRoom
|
||||
let voiceBroadcastStartEvent: MXEvent
|
||||
let voiceBroadcastState: VoiceBroadcastInfo.State
|
||||
let voiceBroadcastState: VoiceBroadcastInfoState
|
||||
let senderDisplayName: String?
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ import Foundation
|
||||
let parameters = VoiceBroadcastPlaybackCoordinatorParameters(session: session,
|
||||
room: room,
|
||||
voiceBroadcastStartEvent: event,
|
||||
voiceBroadcastState: VoiceBroadcastInfo.State(rawValue: voiceBroadcastState) ?? VoiceBroadcastInfo.State.stopped,
|
||||
voiceBroadcastState: VoiceBroadcastInfoState(rawValue: voiceBroadcastState) ?? VoiceBroadcastInfoState.stopped,
|
||||
senderDisplayName: senderDisplayName)
|
||||
guard let coordinator = try? VoiceBroadcastPlaybackCoordinator(parameters: parameters) else {
|
||||
return nil
|
||||
|
||||
+46
-78
@@ -36,11 +36,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
private var displayLink: CADisplayLink!
|
||||
|
||||
private var isLivePlayback = false
|
||||
private var acceptProgressUpdates = true
|
||||
|
||||
private var isPlaybackInitialized: Bool = false
|
||||
private var acceptProgressUpdates: Bool = true
|
||||
private var isActuallyPaused: Bool = false
|
||||
|
||||
private var isPlayingLastChunk: Bool {
|
||||
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
|
||||
guard let chunkDuration = chunks.last?.duration else {
|
||||
return false
|
||||
}
|
||||
|
||||
return state.bindings.progress + 1000 >= state.playingState.duration - Float(chunkDuration)
|
||||
}
|
||||
|
||||
private var isLivePlayback: Bool {
|
||||
return (!isPlaybackInitialized || isPlayingLastChunk) && (state.broadcastState == .started || state.broadcastState == .resumed)
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// MARK: - Setup
|
||||
@@ -54,9 +66,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
self.voiceBroadcastAggregator = voiceBroadcastAggregator
|
||||
|
||||
let viewState = VoiceBroadcastPlaybackViewState(details: details,
|
||||
broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState),
|
||||
broadcastState: voiceBroadcastAggregator.voiceBroadcastState,
|
||||
playbackState: .stopped,
|
||||
playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration)),
|
||||
playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false),
|
||||
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
@@ -81,8 +93,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
switch viewAction {
|
||||
case .play:
|
||||
play()
|
||||
case .playLive:
|
||||
playLive()
|
||||
case .pause:
|
||||
pause()
|
||||
case .sliderChange(let didChange):
|
||||
@@ -95,7 +105,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
|
||||
/// Listen voice broadcast
|
||||
private func play() {
|
||||
isLivePlayback = false
|
||||
displayLink.isPaused = false
|
||||
isActuallyPaused = false
|
||||
|
||||
@@ -105,8 +114,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming")
|
||||
state.playbackState = .buffering
|
||||
voiceBroadcastAggregator.start()
|
||||
|
||||
updateDuration()
|
||||
} else if let audioPlayer = audioPlayer {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume")
|
||||
audioPlayer.play()
|
||||
@@ -120,42 +127,10 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
}
|
||||
}
|
||||
|
||||
private func playLive() {
|
||||
guard isLivePlayback == false else {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Already playing live")
|
||||
return
|
||||
}
|
||||
|
||||
isLivePlayback = true
|
||||
displayLink.isPaused = false
|
||||
isActuallyPaused = false
|
||||
|
||||
// Flush the current audio player playlist
|
||||
audioPlayer?.removeAllPlayerItems()
|
||||
|
||||
if voiceBroadcastAggregator.isStarted == false {
|
||||
// Start the streaming by fetching broadcast chunks
|
||||
// The audio player will automatically start the playback on incoming chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming")
|
||||
state.playbackState = .buffering
|
||||
voiceBroadcastAggregator.start()
|
||||
|
||||
state.playingState.duration = Float(voiceBroadcastAggregator.voiceBroadcast.duration)
|
||||
} else {
|
||||
let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks")
|
||||
|
||||
// Reinject all the chunks we already have and play the last one
|
||||
voiceBroadcastChunkQueue.append(contentsOf: chunks)
|
||||
processPendingVoiceBroadcastChunksForLivePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause voice broadcast
|
||||
private func pause() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause")
|
||||
|
||||
isLivePlayback = false
|
||||
displayLink.isPaused = true
|
||||
isActuallyPaused = true
|
||||
|
||||
@@ -169,9 +144,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
|
||||
// Check if the broadcast is over before stopping everything
|
||||
// If not, the player should not stopped. The view state must be move to buffering
|
||||
// TODO: Define with more accuracy the threshold to detect the end of the playback
|
||||
let remainingTime = state.playingState.duration - state.bindings.progress
|
||||
if remainingTime < 500 {
|
||||
if state.broadcastState == .stopped, isPlayingLastChunk {
|
||||
stop()
|
||||
} else {
|
||||
state.playbackState = .buffering
|
||||
@@ -181,7 +154,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
private func stop() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop")
|
||||
|
||||
isLivePlayback = false
|
||||
displayLink.isPaused = true
|
||||
|
||||
// Objects will be released on audioPlayerDidStopPlaying
|
||||
@@ -254,13 +226,19 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
// Append the chunk to the current playlist
|
||||
audioPlayer.addContentFromURL(result.url)
|
||||
|
||||
if let time = time {
|
||||
audioPlayer.seekToTime(time)
|
||||
}
|
||||
|
||||
// Resume the player. Needed after a buffering
|
||||
if audioPlayer.isPlaying == false && self.state.playbackState == .buffering {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player")
|
||||
self.displayLink.isPaused = false
|
||||
audioPlayer.play()
|
||||
if let time = time {
|
||||
audioPlayer.seekToTime(time)
|
||||
if self.state.playbackState == .buffering {
|
||||
if audioPlayer.isPlaying == false {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player")
|
||||
self.displayLink.isPaused = false
|
||||
audioPlayer.play()
|
||||
} else {
|
||||
self.state.playbackState = .playing
|
||||
self.state.playingState.isLive = self.isLivePlayback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -347,22 +325,6 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
|
||||
|
||||
state.bindings.progress = Float(progress)
|
||||
}
|
||||
|
||||
private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState {
|
||||
var broadcastState: VoiceBroadcastState
|
||||
switch state {
|
||||
case .started:
|
||||
broadcastState = VoiceBroadcastState.live
|
||||
case .paused:
|
||||
broadcastState = VoiceBroadcastState.paused
|
||||
case .resumed:
|
||||
broadcastState = VoiceBroadcastState.live
|
||||
case .stopped:
|
||||
broadcastState = VoiceBroadcastState.stopped
|
||||
}
|
||||
|
||||
return broadcastState
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: VoiceBroadcastAggregatorDelegate
|
||||
@@ -381,14 +343,20 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
|
||||
voiceBroadcastChunkQueue.append(didReceiveChunk)
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) {
|
||||
state.broadcastState = VoiceBroadcastPlaybackViewModel.getBroadcastState(from: didReceiveState)
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfoState) {
|
||||
state.broadcastState = didReceiveState
|
||||
|
||||
// Handle the live icon appearance
|
||||
state.playingState.isLive = isLivePlayback
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
|
||||
if isLivePlayback && state.playbackState == .buffering {
|
||||
// We started directly with a live playback but there was no known chunks at that time
|
||||
// These are the first chunks we get. Start the playback on the latest one
|
||||
|
||||
updateDuration()
|
||||
|
||||
// Handle specifically the case where we were waiting data to start playing a live playback
|
||||
if isLivePlayback, state.playbackState == .buffering {
|
||||
// Start the playback on the latest one
|
||||
processPendingVoiceBroadcastChunksForLivePlayback()
|
||||
} else {
|
||||
processPendingVoiceBroadcastChunks()
|
||||
@@ -403,20 +371,20 @@ extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
|
||||
}
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
if isLivePlayback {
|
||||
state.playbackState = .playingLive
|
||||
} else {
|
||||
state.playbackState = .playing
|
||||
}
|
||||
state.playbackState = .playing
|
||||
state.playingState.isLive = isLivePlayback
|
||||
isPlaybackInitialized = true
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state.playbackState = .paused
|
||||
state.playingState.isLive = false
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying")
|
||||
state.playbackState = .stopped
|
||||
state.playingState.isLive = false
|
||||
release()
|
||||
}
|
||||
|
||||
|
||||
+11
-22
@@ -32,7 +32,7 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if viewModel.viewState.playbackState == .playingLive {
|
||||
if viewModel.viewState.playingState.isLive {
|
||||
return theme.colors.alert
|
||||
}
|
||||
return theme.colors.quarterlyContent
|
||||
@@ -70,20 +70,17 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if viewModel.viewState.broadcastState == .live {
|
||||
Button { viewModel.send(viewAction: .playLive) } label:
|
||||
{
|
||||
Label {
|
||||
Text(VectorL10n.voiceBroadcastLive)
|
||||
.font(theme.fonts.caption1SB)
|
||||
.foregroundColor(Color.white)
|
||||
} icon: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastLive.image)
|
||||
}
|
||||
if viewModel.viewState.broadcastState != .stopped {
|
||||
Label {
|
||||
Text(VectorL10n.voiceBroadcastLive)
|
||||
.font(theme.fonts.caption1SB)
|
||||
.foregroundColor(Color.white)
|
||||
} icon: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastLive.image)
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 4, style: .continuous).fill(backgroundColor))
|
||||
.accessibilityIdentifier("liveButton")
|
||||
.accessibilityIdentifier("liveLabel")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -92,22 +89,14 @@ struct VoiceBroadcastPlaybackView: View {
|
||||
VoiceBroadcastPlaybackErrorView()
|
||||
} else {
|
||||
ZStack {
|
||||
if viewModel.viewState.playbackState == .playing ||
|
||||
viewModel.viewState.playbackState == .playingLive {
|
||||
if viewModel.viewState.playbackState == .playing {
|
||||
Button { viewModel.send(viewAction: .pause) } label: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastPause.image)
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.accessibilityIdentifier("pauseButton")
|
||||
} else {
|
||||
Button {
|
||||
if viewModel.viewState.broadcastState == .live &&
|
||||
viewModel.viewState.playbackState == .stopped {
|
||||
viewModel.send(viewAction: .playLive)
|
||||
} else {
|
||||
viewModel.send(viewAction: .play)
|
||||
}
|
||||
} label: {
|
||||
Button { viewModel.send(viewAction: .play) } label: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastPlay.image)
|
||||
.renderingMode(.original)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import SwiftUI
|
||||
|
||||
enum VoiceBroadcastPlaybackViewAction {
|
||||
case play
|
||||
case playLive
|
||||
case pause
|
||||
case sliderChange(didChange: Bool)
|
||||
}
|
||||
@@ -28,7 +27,6 @@ enum VoiceBroadcastPlaybackState {
|
||||
case stopped
|
||||
case buffering
|
||||
case playing
|
||||
case playingLive
|
||||
case paused
|
||||
case error
|
||||
}
|
||||
@@ -38,21 +36,15 @@ struct VoiceBroadcastPlaybackDetails {
|
||||
let avatarData: AvatarInputProtocol
|
||||
}
|
||||
|
||||
enum VoiceBroadcastState {
|
||||
case unknown
|
||||
case stopped
|
||||
case live
|
||||
case paused
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlayingState {
|
||||
var duration: Float
|
||||
var durationLabel: String?
|
||||
var isLive: Bool
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackViewState: BindableState {
|
||||
var details: VoiceBroadcastPlaybackDetails
|
||||
var broadcastState: VoiceBroadcastState
|
||||
var broadcastState: VoiceBroadcastInfoState
|
||||
var playbackState: VoiceBroadcastPlaybackState
|
||||
var playingState: VoiceBroadcastPlayingState
|
||||
var bindings: VoiceBroadcastPlaybackViewStateBindings
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable {
|
||||
var screenView: ([Any], AnyView) {
|
||||
|
||||
let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room"))
|
||||
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)))
|
||||
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)))
|
||||
|
||||
return (
|
||||
[false, viewModel],
|
||||
|
||||
Reference in New Issue
Block a user