diff --git a/QualityIssues/QIAssets.xcassets/Contents.json b/QualityIssues/QIAssets.xcassets/Contents.json index da4a164..73c0059 100644 --- a/QualityIssues/QIAssets.xcassets/Contents.json +++ b/QualityIssues/QIAssets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/QualityIssues/QIAssets.xcassets/videoOff.imageset/Contents.json b/QualityIssues/QIAssets.xcassets/videoOff.imageset/Contents.json new file mode 100644 index 0000000..cf3edfc --- /dev/null +++ b/QualityIssues/QIAssets.xcassets/videoOff.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "videoOff.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/QualityIssues/QIAssets.xcassets/videoOff.imageset/videoOff.png b/QualityIssues/QIAssets.xcassets/videoOff.imageset/videoOff.png new file mode 100644 index 0000000..0a5ee1e Binary files /dev/null and b/QualityIssues/QIAssets.xcassets/videoOff.imageset/videoOff.png differ diff --git a/QualityIssues/UI/ConferenceParticipantView.swift b/QualityIssues/UI/ConferenceParticipantView.swift new file mode 100644 index 0000000..e4a0b47 --- /dev/null +++ b/QualityIssues/UI/ConferenceParticipantView.swift @@ -0,0 +1,75 @@ +/* +* Copyright (c) 2011-2020, Zingaya, Inc. All rights reserved. +*/ + +import UIKit + +final class ConferenceParticipantView: UIView { + + @IBOutlet private var labelContainer: UIView! + @IBOutlet private var nameLabel: UILabel! + @IBOutlet private var backgroundImage: UIImageView! + @IBOutlet private(set) var streamView: UIView! + + private let cornerRadius: CGFloat = 4 + + var isVideoEnabled: Bool = false { + didSet { streamView.isHidden = !isVideoEnabled } + } + + var isNameEnabled: Bool = false { + didSet { + if let name = name, !name.isEmpty { + nameLabel.isHidden = !isNameEnabled + labelContainer.isHidden = !isNameEnabled + } + } + } + + var name: String? { + get { nameLabel.text } + set { + if let newValue = newValue, !newValue.isEmpty, isNameEnabled { + labelContainer.isHidden = false + } else { + labelContainer.isHidden = true + } + nameLabel.text = newValue + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + sharedInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + sharedInit() + } + + private func sharedInit() { + setupFromNib() + isUserInteractionEnabled = true + contentMode = .scaleAspectFit + streamView.clipsToBounds = true + backgroundImage.layer.cornerRadius = cornerRadius + labelContainer.layer.cornerRadius = cornerRadius + streamView.layer.cornerRadius = cornerRadius + } + + private func setupFromNib() { + if let view = UINib( + nibName: String(describing: Self.self), + bundle: Bundle(for: Self.self) + ).instantiate( + withOwner: self, + options: nil + ).first as? UIView { + addSubview(view) + view.frame = bounds + } else { + fatalError("Error loading \(self) from nib") + } + } +} diff --git a/QualityIssues/UI/ConferenceParticipantView.xib b/QualityIssues/UI/ConferenceParticipantView.xib new file mode 100644 index 0000000..bbe7d4f --- /dev/null +++ b/QualityIssues/UI/ConferenceParticipantView.xib @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/QualityIssues/UI/ConferenceView.swift b/QualityIssues/UI/ConferenceView.swift new file mode 100644 index 0000000..7c14bdb --- /dev/null +++ b/QualityIssues/UI/ConferenceView.swift @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2011-2020, Zingaya, Inc. All rights reserved. + */ + +import UIKit + +@objc class ConferenceView: UIView { + private struct Participant { + let place: Int + var view: ConferenceParticipantView + } + + private struct PendingParticipant { + var view: ConferenceParticipantView + var renderer: ((_ view: UIView?) -> Void)? = nil + } + + private var enlargedParticipantId: String? + private var participants = [String: Participant]() + private var pendingParticipants = [String: PendingParticipant]() + private let maxNumberOfViews = 4 + private var defaultParticipantView: ConferenceParticipantView { + let view = ConferenceParticipantView() + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleParticipant)) + view.addGestureRecognizer(tapGesture) + view.frame = CGRect(x: self.bounds.width / 2, y: self.bounds.height / 2, width: 0, height: 0) + return view + } + + @objc var enlargeParticipantHandler: ((Bool) -> Void)? + + @objc func addParticipant(id: String, name: String? = nil) { + guard participants[id] == nil, pendingParticipants[id] == nil else { + print("addParticipant: unexpected branch") + return + } + let participantView = defaultParticipantView + participantView.name = name + if participants.count < maxNumberOfViews { + addSubview(participantView) + let participant = Participant(place: participants.count, view: participantView) + participants[id] = participant + } else { + let participant = PendingParticipant(view: participantView) + pendingParticipants[id] = participant + } + rearrange() + } + + @objc func removeParticipant(id: String) { + if let participant = participants.removeValue(forKey: id) { + participant.view.removeFromSuperview() + if (id == enlargedParticipantId) { + enlargedParticipantId = nil + } + if participants.count < maxNumberOfViews, + let (participantId, pendingParticipant) = pendingParticipants.first { + pendingParticipants[participantId] = nil + participants[participantId] = Participant( + place: participants.count, + view: pendingParticipant.view + ) + pendingParticipant.renderer?(pendingParticipant.view.streamView) + addSubview(pendingParticipant.view) + } + rearrange() + } else { + pendingParticipants[id] = nil + } + } + + @objc func requestRendering(participantId id: String, render: @escaping (_ view: UIView?) -> Void) { + if let participant = participants[id] { + participant.view.isVideoEnabled = true + render(participant.view.streamView) + } else if pendingParticipants[id] != nil { + pendingParticipants[id]?.renderer = render + } else { + print("requestRendering: unexpected branch") + } + } + + @objc func stopRendering(participantId id: String) { + if let participant = participants[id] { + participant.view.isVideoEnabled = false + } else if let pendingParticipant = pendingParticipants[id] { + pendingParticipant.view.isVideoEnabled = false + pendingParticipants[id]?.renderer = nil + } + } + + @objc func updateParticipantName(id: String, name: String) { + if let participantView = participants[id]?.view { + participantView.name = name + } else if let pendingParticipantView = pendingParticipants[id]?.view { + pendingParticipantView.name = name + } + } + + @objc private func toggleParticipant(_ sender: UIGestureRecognizer!) { + if let participantView = sender.view as? ConferenceParticipantView, + let id = participants.first(where: { _, value in value.view == participantView })?.key { + if id == enlargedParticipantId { + enlargedParticipantId = nil + } else { + enlargedParticipantId = id + } + } + rearrange() + } + + override func layoutSubviews() { + rearrange() + super.layoutSubviews() + } + + private func rearrange() { + DispatchQueue.main.async { () -> Void in + self.enlargeParticipantHandler?(self.enlargedParticipantId != nil) + + let surface = self.bounds.size + + if let rootParticipant = self.enlargedParticipantId { + guard let rootParticipantView = self.participants[rootParticipant]?.view + else { return } + self.participants.values.forEach { $0.view.alpha = 0 } + rootParticipantView.isNameEnabled = true + rootParticipantView.frame = CGRect(x: 0, y: 0, width: surface.width, height: surface.height) + rootParticipantView.alpha = 1 + + } else { + var w, h: CGFloat + + switch self.participants.count { + case 0..<2: + w = 1; h = 1 + case 2: + w = 2; h = 1 + case 3..<5: + w = 2; h = 2 + default: + return + } + + let size = CGSize(width: surface.width / w, height: surface.height / h) + self.participants.values.forEach { participant in + let view = participant.view + view.alpha = 1 + view.isNameEnabled = false + let x = participant.place % Int(w) + let y = participant.place / Int(w) + view.frame = CGRect( + origin: CGPoint(x: CGFloat(x) * size.width, y: CGFloat(y) * size.height), + size: size + ) + view.layoutSubviews() + } + } + } + } +}