Conference Active Speaker adjustments :

- Leave local participant always first (portrait) or top (landscape)
- Layout for 0, 1 and more participants
This commit is contained in:
Christophe Deschamps 2022-08-23 22:44:56 +02:00
parent 6a29d1d0e2
commit 1e886125b9
5 changed files with 187 additions and 38 deletions

View file

@ -147,7 +147,6 @@ import UIKit
static let camera_required_for_video = NSLocalizedString("Camera use is not Authorized for &appName;. This permission is required to activate Video.",comment:"").replacingOccurrences(of: "&appName;", with: appName)
static let conference_edit_error = NSLocalizedString("Unable to edit conference this time, date is invalid",comment:"")
static let ok = NSLocalizedString("ok",comment:"")
static let conference_display_no_active_speaker = NSLocalizedString("No active speaker",comment:"")
static let conference_info_confirm_removal_delete = NSLocalizedString("DELETE",comment:"")
static let conference_unable_to_share_via_calendar = NSLocalizedString("Unable to add event to calendar. Check permissions",comment:"")
}

View file

@ -85,6 +85,9 @@ class ConferenceParticipantDeviceData {
isInConference.value = participantDevice.isInConference
let videoCapability = participantDevice.getStreamCapability(streamType: .Video)
isJoining.value = [.Joining,.Alerting].contains(participantDevice.state)
Log.i("[Conference Participant Device] Participant [\(participantDevice.address?.asStringUriOnly())], is in conf? \(isInConference.value), is video enabled? \(videoEnabled.value) \(videoCapability)")
}
@ -97,6 +100,8 @@ class ConferenceParticipantDeviceData {
isInConference.clearObservers()
videoEnabled.clearObservers()
isSpeaking.clearObservers()
isJoining.clearObservers()
micMuted.clearObservers()
}
func switchCamera() {

View file

@ -39,19 +39,24 @@ class ConferenceViewModel {
let conferenceParticipants = MutableLiveData<[ConferenceParticipantData]>()
let conferenceParticipantDevices = MutableLiveData<[ConferenceParticipantDeviceData]>()
let conferenceDisplayMode = MutableLiveData<ConferenceDisplayMode>()
let activeSpeakerConferenceParticipantDevices = MutableLiveData<[ConferenceParticipantDeviceData]>()
let isRecording = MutableLiveData<Bool>()
let isRemotelyRecorded = MutableLiveData<Bool>()
let maxParticipantsForMosaicLayout = ConfigManager.instance().lpConfigIntForKey(key: "max_conf_part_mosaic_layout",defaultValue: 6)
let moreThanTwoParticipants = MutableLiveData<Bool>()
let speakingParticipant = MutableLiveData<ConferenceParticipantDeviceData>()
let meParticipant = MutableLiveData<ConferenceParticipantDeviceData>()
let participantAdminStatusChangedEvent = MutableLiveData<ConferenceParticipantData>()
let firstToJoinEvent = MutableLiveData<Bool>()
let firstToJoinEvent = MutableLiveData(false)
let allParticipantsLeftEvent = MutableLiveData<Bool>()
let allParticipantsLeftEvent = MutableLiveData(false)
private var conferenceDelegate : ConferenceDelegateStub?
private var coreDelegate : CoreDelegateStub?
@ -80,7 +85,6 @@ class ConferenceViewModel {
onParticipantDeviceAdded: {(conference: Conference, participantDevice: ParticipantDevice) in
Log.i("[Conference] \(conference) Participant device \(participantDevice) added")
self.addParticipantDevice(device: participantDevice)
},
onParticipantDeviceRemoved: { (conference: Conference, participantDevice: ParticipantDevice) in
Log.i("[Conference] \(conference) Participant device \(participantDevice) removed")
@ -165,6 +169,7 @@ class ConferenceViewModel {
}
}
}
}
func notifyAdminStatusChanged(participantData:ConferenceParticipantData) {
@ -214,7 +219,7 @@ class ConferenceViewModel {
firstToJoinEvent.value = true
}
self.updateParticipantsDevicesList(conference)
isConferenceLocallyPaused.value = !conference.isIn
self.isMeAdmin.value = conference.me?.isAdmin == true
isVideoConference.value = conference.currentParams?.videoEnabled == true
@ -276,9 +281,12 @@ class ConferenceViewModel {
self.conferenceParticipants.value?.forEach{ $0.destroy()}
self.conferenceParticipantDevices.value?.forEach{ $0.destroy()}
conferenceParticipants.clearObservers()
conferenceParticipants.value = []
conferenceParticipantDevices.clearObservers()
conferenceParticipantDevices.value = []
speakingParticipant.value = nil
meParticipant.value = nil
}
@ -325,11 +333,8 @@ class ConferenceViewModel {
conference.me?.devices.forEach { (device) in
Log.i("[Conference] \(conference) Participant device for myself found: \(device.name) (\(device.address!.asStringUriOnly()))")
let deviceData = ConferenceParticipantDeviceData(participantDevice: device, isMe: true)
if (devices.count == 0) {
// TODO: FIXME: Temporary workaround when alone in a conference in active speaker layout
speakingParticipant.value = deviceData
}
devices.append(deviceData)
meParticipant.value = deviceData
}
@ -362,6 +367,10 @@ class ConferenceViewModel {
let devices = conferenceParticipantDevices.value?.filter {
$0.participantDevice.address?.asStringUriOnly() != device.address?.asStringUriOnly()
}
conferenceParticipantDevices.value?.filter {
$0.participantDevice.address?.asStringUriOnly() == device.address?.asStringUriOnly()
}.first?.destroy()
if (devices?.count == conferenceParticipantDevices.value?.count) {
Log.e("[Conference] Failed to remove participant device: \(device.name) (\((device.address?.asStringUriOnly()).orNil)")
} else {

View file

@ -33,8 +33,10 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
let record_pause_button_inset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)
let grid_height = 100.0
let cell_width = 100.0
let switch_camera_button_size = 35
let switch_camera_button_margins = 7.0
let switchCamera = UIImageView(image: UIImage(named:"voip_change_camera")?.tinted(with:.white))
let subjectLabel = StyledLabel(VoipTheme.call_display_name_duration)
let duration = CallTimer(nil, VoipTheme.call_display_name_duration)
@ -48,19 +50,59 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
let activeSpeakerDisplayName = StyledLabel(VoipTheme.call_remote_name)
var grid : UICollectionView
var meGrid : UICollectionView
let layout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
var fullScreenOpaqueMasqForNotchedDevices = UIView()
let conferenceJoinSpinner = RotatingSpinner()
var conferenceViewModel: ConferenceViewModel? = nil {
didSet {
if let model = conferenceViewModel {
self.setJoininngSpeakerState(enabled: true)
self.activeSpeakerAvatar.showAsAvatarIcon()
model.subject.readCurrentAndObserve { (subject) in
self.subjectLabel.text = subject
}
duration.conference = model.conference.value
self.remotelyRecording.isRemotelyRecorded = model.isRemotelyRecorded
model.conferenceParticipantDevices.readCurrentAndObserve { (_) in
model.conferenceParticipantDevices.readCurrentAndObserve { value in
model.activeSpeakerConferenceParticipantDevices.value = Array((value!.dropFirst()))
}
model.activeSpeakerConferenceParticipantDevices.readCurrentAndObserve { (_) in
self.reloadData()
let otherSpeakersCount = model.activeSpeakerConferenceParticipantDevices.value!.count
self.switchCamera.isHidden = true
if (otherSpeakersCount == 0) {
Core.get().nativeVideoWindow = self.activeSpeakerVideoView
self.layoutRotatableElements()
self.meGrid.isHidden = true
self.grid.isHidden = true
model.meParticipant.value?.videoEnabled.readCurrentAndObserve { video in
self.switchCamera.isHidden = video != true
self.fillActiveSpeakerSpace(data: model.meParticipant.value,video: video == true)
}
} else if (otherSpeakersCount == 1) {
Core.get().nativeVideoWindow = self.activeSpeakerVideoView
if let data = model.activeSpeakerConferenceParticipantDevices.value!.first {
data.videoEnabled.readCurrentAndObserve { video in
self.fillActiveSpeakerSpace(data: data,video: video == true)
}
}
self.layoutRotatableElements()
self.meGrid.isHidden = false
self.grid.isHidden = true
} else if (otherSpeakersCount == 2) {
Core.get().nativeVideoWindow = self.activeSpeakerVideoView
self.meGrid.isHidden = false
self.grid.isHidden = false
self.layoutRotatableElements()
} else {
Core.get().nativeVideoWindow = self.activeSpeakerVideoView
self.meGrid.isHidden = false
self.grid.isHidden = false
}
}
model.isConferenceLocallyPaused.readCurrentAndObserve { (paused) in
self.pauseCallButtons.forEach {
@ -73,16 +115,10 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
}
}
Core.get().nativeVideoWindow = self.activeSpeakerVideoView
self.activeSpeakerAvatar.isHidden = true
self.activeSpeakerVideoView.isHidden = true
self.activeSpeakerDisplayName.text = VoipTexts.conference_display_no_active_speaker
conferenceViewModel?.speakingParticipant.readCurrentAndObserve { speakingParticipant in
speakingParticipant?.participantDevice.address.map {
self.activeSpeakerAvatar.isHidden = false
self.activeSpeakerAvatar.fillFromAddress(address: $0)
self.activeSpeakerDisplayName.text = $0.addressBookEnhancedDisplayName()
model.speakingParticipant.readCurrentAndObserve { speakingParticipant in
if (model.activeSpeakerConferenceParticipantDevices.value!.count > 1) {
self.fillActiveSpeakerSpace(data: speakingParticipant,video: speakingParticipant?.videoEnabled.value == true)
}
self.activeSpeakerVideoView.isHidden = speakingParticipant?.videoEnabled.value != true
}
}
self.reloadData()
@ -90,11 +126,36 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
}
}
func setJoininngSpeakerState(enabled: Bool) {
if (!enabled) {
self.conferenceJoinSpinner.isHidden = true
self.conferenceJoinSpinner.stopRotation()
} else {
self.conferenceJoinSpinner.isHidden = false
self.conferenceJoinSpinner.startRotation()
}
}
func fillActiveSpeakerSpace(data: ConferenceParticipantDeviceData?, video: Bool) {
data?.isJoining.readCurrentAndObserve { joining in
self.setJoininngSpeakerState(enabled: joining == true || data?.participantDevice.address == nil)
}
if let address = data?.participantDevice.address {
self.activeSpeakerAvatar.fillFromAddress(address: address)
self.activeSpeakerDisplayName.text = address.addressBookEnhancedDisplayName()
} else {
self.activeSpeakerAvatar.showAsAvatarIcon()
self.activeSpeakerDisplayName.text = nil
}
self.activeSpeakerVideoView.isHidden = !video
}
func reloadData() {
conferenceViewModel?.conferenceParticipantDevices.value?.forEach {
conferenceViewModel?.activeSpeakerConferenceParticipantDevices.value?.forEach {
$0.clearObservers()
}
self.grid.reloadData()
self.meGrid.reloadData()
}
init() {
@ -105,6 +166,13 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
layout.itemSize = CGSize(width:cell_width, height:grid_height)
grid = UICollectionView(frame:.zero, collectionViewLayout: layout)
let meLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout()
meLayout.scrollDirection = .horizontal
meLayout.minimumInteritemSpacing = 0
meLayout.minimumLineSpacing = 0
meLayout.itemSize = CGSize(width:cell_width, height:grid_height)
meGrid = UICollectionView(frame:.zero, collectionViewLayout: meLayout)
super.init(frame: .zero)
let headerView = UIStackView()
@ -177,17 +245,40 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
activeSpeakerView.addSubview(activeSpeakerVideoView)
activeSpeakerVideoView.matchParentDimmensions().done()
activeSpeakerView.addSubview(switchCamera)
switchCamera.contentMode = .scaleAspectFit
switchCamera.onClick {
Core.get().videoPreviewEnabled = false
Core.get().toggleCamera()
Core.get().nativePreviewWindow = self.activeSpeakerVideoView
Core.get().videoPreviewEnabled = true
}
activeSpeakerView.addSubview(conferenceJoinSpinner)
conferenceJoinSpinner.square(IncomingOutgoingCommonView.spinner_size).center().done()
switchCamera.alignParentTop(withMargin: switch_camera_button_margins).alignParentRight(withMargin: switch_camera_button_margins).square(switch_camera_button_size).done()
activeSpeakerView.addSubview(activeSpeakerDisplayName)
activeSpeakerDisplayName.alignParentLeft(withMargin:ActiveCallView.bottom_displayname_margin_left).alignParentRight().alignParentBottom(withMargin:ActiveCallView.bottom_displayname_margin_bottom).done()
// CollectionView
// CollectionViews
grid.dataSource = self
grid.delegate = self
grid.register(VoipActiveSpeakerParticipantCell.self, forCellWithReuseIdentifier: "VoipActiveSpeakerParticipantCell")
grid.backgroundColor = .clear
grid.isScrollEnabled = true
fullScreenMutableView.addSubview(grid)
meGrid.dataSource = self
meGrid.delegate = self
meGrid.register(VoipActiveSpeakerParticipantCell.self, forCellWithReuseIdentifier: "VoipActiveSpeakerParticipantCell")
meGrid.backgroundColor = .clear
meGrid.isScrollEnabled = false
fullScreenMutableView.addSubview(meGrid)
// Full screen video togggle
activeSpeakerView.onClick {
@ -217,6 +308,9 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
self.addSubview(fullScreenMutableView)
fullScreenMutableView.matchParentSideBorders().alignUnder(view:headerView,withMargin: ActiveCallView.center_view_margin_top).alignParentBottom().done()
}
UIView.animate(withDuration: 0.3, animations: {
self.layoutIfNeeded()
})
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.reloadData()
}
@ -224,31 +318,68 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
//Rotation
layoutRotatableElements()
}
// Rotations
func bounceGrids() {
let superView = grid.superview
grid.removeFromSuperview()
meGrid.removeFromSuperview()
superView?.addSubview(grid)
superView?.addSubview(meGrid)
}
func layoutRotatableElements() {
grid.removeConstraints().done()
meGrid.removeConstraints().done()
activeSpeakerView.removeConstraints().done()
activeSpeakerAvatar.removeConstraints().done()
let otherParticipantsCount = conferenceViewModel?.activeSpeakerConferenceParticipantDevices.value!.count
if ([.landscapeLeft, .landscapeRight].contains( UIDevice.current.orientation)) {
activeSpeakerView.alignParentTop().alignParentBottom().alignParentLeft().toLeftOf(grid,withRightMargin: ActiveCallOrConferenceView.content_inset).done()
if (UIDevice.current.orientation == .landscapeLeft) { // work around some constraints issues with Notch on the left.
let superView = grid.superview
grid.removeFromSuperview()
superView?.addSubview(grid)
if (otherParticipantsCount == 0) {
activeSpeakerView.matchParentDimmensions().done()
activeSpeakerAvatar.square(Avatar.diameter_for_call_views_land).center().done()
if (UIDevice.current.orientation == .landscapeLeft) { // work around some constraints issues with Notch on the left.
bounceGrids()
}
} else if (otherParticipantsCount == 1) {
activeSpeakerView.matchParentDimmensions().done()
if (UIDevice.current.orientation == .landscapeLeft) { // work around some constraints issues with Notch on the left.
bounceGrids()
}
activeSpeakerAvatar.square(Avatar.diameter_for_call_views_land).center().done()
meGrid.alignParentRight(withMargin: ActiveCallView.center_view_margin_top).height(grid_height).width(grid_height).alignParentBottom(withMargin: ActiveCallView.center_view_margin_top).done()
} else {
activeSpeakerView.alignParentTop().alignParentBottom().alignParentLeft().toLeftOf(grid,withRightMargin: ActiveCallOrConferenceView.content_inset).done()
if (UIDevice.current.orientation == .landscapeLeft) { // work around some constraints issues with Notch on the left.
bounceGrids()
}
meGrid.width(grid_height).height(grid_height).toRightOf(activeSpeakerView,withLeftMargin: ActiveCallOrConferenceView.content_inset).alignParentTop().alignParentRight().done()
grid.width(grid_height).toRightOf(activeSpeakerView,withLeftMargin: ActiveCallOrConferenceView.content_inset).alignUnder(view: meGrid, withMargin: ActiveCallOrConferenceView.content_inset).alignParentBottom().alignParentRight().done()
layout.scrollDirection = .vertical
activeSpeakerAvatar.square(Avatar.diameter_for_call_views_land).center().done()
}
grid.width(grid_height).toRightOf(activeSpeakerView,withLeftMargin: ActiveCallOrConferenceView.content_inset).alignParentTop().alignParentBottom().alignParentRight().done()
layout.scrollDirection = .vertical
activeSpeakerAvatar.square(Avatar.diameter_for_call_views_land).center().done()
} else {
activeSpeakerAvatar.square(Avatar.diameter_for_call_views).center().done()
activeSpeakerView.matchParentSideBorders().alignParentTop().done()
grid.matchParentSideBorders().height(grid_height).alignParentBottom().alignUnder(view: activeSpeakerView, withMargin:ActiveCallView.center_view_margin_top).done()
layout.scrollDirection = .horizontal
if (otherParticipantsCount == 0) {
activeSpeakerView.matchParentDimmensions().done()
activeSpeakerAvatar.square(Avatar.diameter_for_call_views).center().done()
} else if (otherParticipantsCount == 1) {
activeSpeakerView.matchParentDimmensions().done()
activeSpeakerAvatar.square(Avatar.diameter_for_call_views).center().done()
meGrid.alignParentRight(withMargin: ActiveCallView.center_view_margin_top).height(grid_height).width(grid_height).alignParentBottom(withMargin: ActiveCallView.center_view_margin_top).done()
} else {
activeSpeakerAvatar.square(Avatar.diameter_for_call_views).center().done()
activeSpeakerView.matchParentSideBorders().alignParentTop().done()
meGrid.alignParentLeft().height(grid_height).width(grid_height).alignParentBottom().alignUnder(view: activeSpeakerView, withMargin:ActiveCallView.center_view_margin_top).done()
grid.toRightOf(meGrid,withLeftMargin: ActiveCallOrConferenceView.content_inset).height(grid_height).alignParentRight().alignParentBottom().alignUnder(view: activeSpeakerView, withMargin:ActiveCallView.center_view_margin_top).done()
layout.scrollDirection = .horizontal
}
}
UIView.animate(withDuration: 0.3, animations: {
self.layoutIfNeeded()
})
}
// UICollectionView related delegates
@ -267,7 +398,7 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
if (self.isHidden || conferenceViewModel?.conference.value?.call?.params?.conferenceVideoLayout != .ActiveSpeaker) {
return 0
}
guard let participantsCount = conferenceViewModel?.conferenceParticipantDevices.value?.count else {
guard let participantsCount = collectionView == meGrid ? (conferenceViewModel?.meParticipant.value != nil ? 1 : 0) : conferenceViewModel?.activeSpeakerConferenceParticipantDevices.value?.count else {
return .zero
}
return participantsCount
@ -275,7 +406,7 @@ class VoipConferenceActiveSpeakerView: UIView, UICollectionViewDataSource, UICol
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell:VoipActiveSpeakerParticipantCell = collectionView.dequeueReusableCell(withReuseIdentifier: "VoipActiveSpeakerParticipantCell", for: indexPath) as! VoipActiveSpeakerParticipantCell
guard let participantData = conferenceViewModel?.conferenceParticipantDevices.value?[indexPath.row] else {
guard let participantData = collectionView == meGrid ? conferenceViewModel?.meParticipant.value : conferenceViewModel?.activeSpeakerConferenceParticipantDevices.value?[indexPath.row] else {
return cell
}
cell.participantData = participantData

View file

@ -62,6 +62,11 @@ class Avatar : UIImageView {
}
}
}
func showAsAvatarIcon() {
self.image = UIImage(named:"avatar")?.tinted(with: .white)
initialsLabel.isHidden = true
}
override func layoutSubviews() {
super.layoutSubviews()