Add voice broadcast slider (#7010)

This commit is contained in:
Yoan Pintas
2022-11-14 17:16:14 +01:00
committed by GitHub
parent 3380f31d9c
commit 91c5936a24
14 changed files with 255 additions and 74 deletions
@@ -76,4 +76,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
}
func endVoiceBroadcast() {}
func pausePlaying() {
viewModel.context.send(viewAction: .pause)
}
}
@@ -16,13 +16,22 @@
import Foundation
class VoiceBroadcastPlaybackProvider {
static let shared = VoiceBroadcastPlaybackProvider()
@objc class VoiceBroadcastPlaybackProvider: NSObject {
@objc static let shared = VoiceBroadcastPlaybackProvider()
var session: MXSession?
var session: MXSession? {
willSet {
guard let currentSession = self.session else { return }
if currentSession != newValue {
// Clear all stored coordinators on new session
coordinatorsForEventIdentifiers.removeAll()
}
}
}
var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]()
private init() { }
private override init() { }
/// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
/// a view to be displayed in the timeline
@@ -54,4 +63,11 @@ class VoiceBroadcastPlaybackProvider {
func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? {
coordinatorsForEventIdentifiers[eventIdentifier]
}
/// Pause current voice broadcast playback.
@objc public func pausePlaying() {
coordinatorsForEventIdentifiers.forEach { _, coordinator in
coordinator.pausePlaying()
}
}
}
@@ -26,14 +26,20 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
// MARK: - Properties
// MARK: Private
private var voiceBroadcastAggregator: VoiceBroadcastAggregator
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
private let cacheManager: VoiceMessageAttachmentCacheManager
private var audioPlayer: VoiceMessageAudioPlayer?
private var voiceBroadcastAggregator: VoiceBroadcastAggregator
private var voiceBroadcastChunkQueue: [VoiceBroadcastChunk] = []
private var voiceBroadcastAttachmentCacheManagerLoadResults: [VoiceMessageAttachmentCacheManagerLoadResult] = []
private var audioPlayer: VoiceMessageAudioPlayer?
private var displayLink: CADisplayLink!
private var isLivePlayback = false
private var acceptProgressUpdates = true
private var isActuallyPaused: Bool = false
// MARK: Public
@@ -50,9 +56,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
let viewState = VoiceBroadcastPlaybackViewState(details: details,
broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState),
playbackState: .stopped,
bindings: VoiceBroadcastPlaybackViewStateBindings())
playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration)),
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))
super.init(initialViewState: viewState)
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
displayLink.isPaused = true
displayLink.add(to: .current, forMode: .common)
self.voiceBroadcastAggregator.delegate = self
}
@@ -74,6 +85,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
playLive()
case .pause:
pause()
case .sliderChange(let didChange):
didSliderChanged(didChange)
}
}
@@ -83,6 +96,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
/// Listen voice broadcast
private func play() {
isLivePlayback = false
displayLink.isPaused = false
isActuallyPaused = false
if voiceBroadcastAggregator.isStarted == false {
// Start the streaming by fetching broadcast chunks
@@ -90,16 +105,16 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming")
state.playbackState = .buffering
voiceBroadcastAggregator.start()
}
else if let audioPlayer = audioPlayer {
updateDuration()
} else if let audioPlayer = audioPlayer {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume")
audioPlayer.play()
}
else {
} else {
let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks")
// Reinject all the chuncks we already have and play them
// Reinject all the chunks we already have and play them
voiceBroadcastChunkQueue.append(contentsOf: chunks)
processPendingVoiceBroadcastChunks()
}
@@ -112,6 +127,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
}
isLivePlayback = true
displayLink.isPaused = false
isActuallyPaused = false
// Flush the current audio player playlist
audioPlayer?.removeAllPlayerItems()
@@ -122,22 +139,25 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming")
state.playbackState = .buffering
voiceBroadcastAggregator.start()
}
else {
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 chuncks we already have and play the last one
// Reinject all the chunks we already have and play the last one
voiceBroadcastChunkQueue.append(contentsOf: chunks)
processPendingVoiceBroadcastChunksForLivePlayback()
}
}
/// Stop voice broadcast
/// Pause voice broadcast
private func pause() {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause")
isLivePlayback = false
displayLink.isPaused = true
isActuallyPaused = true
if let audioPlayer = audioPlayer, audioPlayer.isPlaying {
audioPlayer.pause()
@@ -147,15 +167,22 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
private func stopIfVoiceBroadcastOver() {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver")
// TODO: Check if the broadcast is over before stopping everything
// Check if the broadcast is over before stopping everything
// If not, the player should not stopped. The view state must be move to buffering
stop()
// 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 {
stop()
} else {
state.playbackState = .buffering
}
}
private func stop() {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop")
isLivePlayback = false
displayLink.isPaused = true
// Objects will be released on audioPlayerDidStopPlaying
audioPlayer?.stop()
@@ -165,9 +192,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
// MARK: - Voice broadcast chunks playback
/// Start the playback from the beginning or push more chunks to it
private func processPendingVoiceBroadcastChunks() {
private func processPendingVoiceBroadcastChunks(_ time: TimeInterval? = nil) {
reorderPendingVoiceBroadcastChunks()
processNextVoiceBroadcastChunk()
processNextVoiceBroadcastChunk(time)
}
/// Start the playback from the last known chunk
@@ -188,7 +215,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
chunks.sorted(by: {$0.sequence < $1.sequence})
}
private func processNextVoiceBroadcastChunk() {
private func processNextVoiceBroadcastChunk(_ time: TimeInterval? = nil) {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining")
guard voiceBroadcastChunkQueue.count > 0 else {
@@ -196,6 +223,10 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
return
}
if (isActuallyPaused == false && state.playbackState == .paused) || state.playbackState == .stopped {
state.playbackState = .buffering
}
// TODO: Control the download rate to avoid to download all chunk in mass
// We could synchronise it with the number of chunks in the player playlist (audioPlayer.playerItems)
@@ -210,45 +241,113 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
// TODO: Make sure there has no new incoming chunk that should be before this attachment
// Be careful that this new chunk is not older than the chunk being played by the audio player. Else
// we will get an unexecpted rewind.
switch result {
case .success(let result):
guard result.eventIdentifier == chunk.attachment.eventId else {
return
}
case .success(let result):
guard result.eventIdentifier == chunk.attachment.eventId else {
return
}
self.voiceBroadcastAttachmentCacheManagerLoadResults.append(result)
if let audioPlayer = self.audioPlayer {
// Append the chunk to the current playlist
audioPlayer.addContentFromURL(result.url)
if let audioPlayer = self.audioPlayer {
// Append the chunk to the current playlist
audioPlayer.addContentFromURL(result.url)
// Resume the player. Needed after a pause
if audioPlayer.isPlaying == false {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player")
audioPlayer.play()
// 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)
}
}
else {
// Init and start the player on the first chunk
let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
audioPlayer.registerDelegate(self)
audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
audioPlayer.play()
self.audioPlayer = audioPlayer
}
case .failure (let error):
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error)
if self.voiceBroadcastChunkQueue.count == 0 {
// No more chunk to try. Go to error
self.state.playbackState = .error
} else {
// Init and start the player on the first chunk
let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
audioPlayer.registerDelegate(self)
audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
self.displayLink.isPaused = false
audioPlayer.play()
if let time = time {
audioPlayer.seekToTime(time)
}
self.audioPlayer = audioPlayer
}
case .failure (let error):
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error)
if self.voiceBroadcastChunkQueue.count == 0 {
// No more chunk to try. Go to error
self.state.playbackState = .error
}
}
self.processNextVoiceBroadcastChunk()
}
}
private func updateDuration() {
let duration = voiceBroadcastAggregator.voiceBroadcast.duration
let time = TimeInterval(duration / 1000)
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
state.playingState.duration = Float(duration)
state.playingState.durationLabel = formatter.string(from: time)
}
private func didSliderChanged(_ didChange: Bool) {
acceptProgressUpdates = !didChange
if didChange {
audioPlayer?.pause()
displayLink.isPaused = true
} else {
// Flush the current audio player playlist
audioPlayer?.removeAllPlayerItems()
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
// Reinject the chunks we need and play them
let remainingTime = state.playingState.duration - state.bindings.progress
var chunksDuration: UInt = 0
for chunk in chunks.reversed() {
chunksDuration += chunk.duration
voiceBroadcastChunkQueue.append(chunk)
if Float(chunksDuration) >= remainingTime {
break
}
}
MXLog.debug("[VoiceBroadcastPlaybackViewModel] didSliderChanged: restart to time: \(state.bindings.progress) milliseconds")
let time = state.bindings.progress - state.playingState.duration + Float(chunksDuration)
processPendingVoiceBroadcastChunks(TimeInterval(time / 1000))
}
}
@objc private func handleDisplayLinkTick() {
updateUI()
}
private func updateUI() {
guard let playingEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in
result.url == audioPlayer?.currentUrl
})?.eventIdentifier,
let playingSequence = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in
chunk.attachment.eventId == playingEventId
})?.sequence else {
return
}
let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in
chunk.sequence < playingSequence
}.reduce(0) { $0 + $1.duration}) + (audioPlayer?.currentTime.rounded() ?? 0) * 1000
state.bindings.progress = Float(progress)
}
private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState {
var broadcastState: VoiceBroadcastState
switch state {
@@ -288,11 +387,10 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
if isLivePlayback && state.playbackState == .buffering {
// We started directly with a live playback but there was no known chuncks at that time
// 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
processPendingVoiceBroadcastChunksForLivePlayback()
}
else {
} else {
processPendingVoiceBroadcastChunks()
}
}
@@ -307,8 +405,7 @@ extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
if isLivePlayback {
state.playbackState = .playingLive
}
else {
} else {
state.playbackState = .playing
}
}
@@ -117,6 +117,16 @@ struct VoiceBroadcastPlaybackView: View {
}
.activityIndicator(show: viewModel.viewState.playbackState == .buffering)
}
Slider(value: $viewModel.progress, in: 0...viewModel.viewState.playingState.duration) {
Text("Slider")
} minimumValueLabel: {
Text("")
} maximumValueLabel: {
Text(viewModel.viewState.playingState.durationLabel ?? "").font(.body)
} onEditingChanged: { didChange in
viewModel.send(viewAction: .sliderChange(didChange: didChange))
}
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])
@@ -21,6 +21,7 @@ enum VoiceBroadcastPlaybackViewAction {
case play
case playLive
case pause
case sliderChange(didChange: Bool)
}
enum VoiceBroadcastPlaybackState {
@@ -44,13 +45,20 @@ enum VoiceBroadcastState {
case paused
}
struct VoiceBroadcastPlayingState {
var duration: Float
var durationLabel: String?
}
struct VoiceBroadcastPlaybackViewState: BindableState {
var details: VoiceBroadcastPlaybackDetails
var broadcastState: VoiceBroadcastState
var playbackState: VoiceBroadcastPlaybackState
var playingState: VoiceBroadcastPlayingState
var bindings: VoiceBroadcastPlaybackViewStateBindings
}
struct VoiceBroadcastPlaybackViewStateBindings {
var progress: Float
}
@@ -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, bindings: VoiceBroadcastPlaybackViewStateBindings()))
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)))
return (
[false, viewModel],