Skip to content

Commit 853c12f

Browse files
authored
VideoView: detect rendering / freeze (#90)
* impl * expose properties * clean up * revert to private * update isRendering for SwiftUIVideoView * update delegates * adjust access * mutate can be async for render * swift ui fixes
1 parent 083e949 commit 853c12f

File tree

6 files changed

+129
-65
lines changed

6 files changed

+129
-65
lines changed

Sources/LiveKit/Protocols/TrackDelegate.swift

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,9 @@
1717
import Foundation
1818
import WebRTC
1919

20-
// TODO: Make this internal
21-
// Currently used for internal purposes
2220
public protocol TrackDelegate: AnyObject {
2321
/// Dimensions of the video track has updated
2422
func track(_ track: VideoTrack, didUpdate dimensions: Dimensions?)
25-
/// Dimensions of the VideoView has updated
26-
func track(_ track: VideoTrack, videoView: VideoView, didUpdate size: CGSize)
27-
/// VideoView updated the render state
28-
func track(_ track: VideoTrack, videoView: VideoView, didUpdate renderState: VideoView.RenderState)
2923
/// A ``VideoView`` was attached to the ``VideoTrack``
3024
func track(_ track: VideoTrack, didAttach videoView: VideoView)
3125
/// A ``VideoView`` was detached from the ``VideoTrack``
@@ -38,12 +32,10 @@ public protocol TrackDelegate: AnyObject {
3832

3933
// MARK: - Optional
4034

41-
extension TrackDelegate {
42-
public func track(_ track: VideoTrack, didUpdate dimensions: Dimensions?) {}
43-
public func track(_ track: VideoTrack, videoView: VideoView, didUpdate size: CGSize) {}
44-
public func track(_ track: VideoTrack, videoView: VideoView, didUpdate renderState: VideoView.RenderState) {}
45-
public func track(_ track: VideoTrack, didAttach videoView: VideoView) {}
46-
public func track(_ track: VideoTrack, didDetach videoView: VideoView) {}
47-
public func track(_ track: Track, didUpdate muted: Bool, shouldSendSignal: Bool) {}
48-
public func track(_ track: Track, didUpdate stats: TrackStats) {}
35+
public extension TrackDelegate {
36+
func track(_ track: VideoTrack, didUpdate dimensions: Dimensions?) {}
37+
func track(_ track: VideoTrack, didAttach videoView: VideoView) {}
38+
func track(_ track: VideoTrack, didDetach videoView: VideoView) {}
39+
func track(_ track: Track, didUpdate muted: Bool, shouldSendSignal: Bool) {}
40+
func track(_ track: Track, didUpdate stats: TrackStats) {}
4941
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2022 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Foundation
18+
import WebRTC
19+
20+
public protocol VideoViewDelegate: AnyObject {
21+
/// Dimensions of the VideoView itself has updated
22+
func videoView(_ videoView: VideoView, didUpdate size: CGSize)
23+
/// VideoView updated the isRendering property
24+
func videoView(_ videoView: VideoView, didUpdate isRendering: Bool)
25+
}
26+
27+
// MARK: - Optional
28+
29+
public extension VideoViewDelegate {
30+
func videoView(_ videoView: VideoView, didUpdate size: CGSize) {}
31+
func videoView(_ videoView: VideoView, didUpdate isRendering: Bool) {}
32+
}

Sources/LiveKit/Publications/TrackPublication.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,18 +133,6 @@ public class TrackPublication: TrackDelegate, Loggable {
133133

134134
// MARK: - TrackDelegate
135135

136-
public func track(_ track: VideoTrack, videoView: VideoView, didUpdate size: CGSize) {
137-
//
138-
}
139-
140-
public func track(_ track: VideoTrack, didAttach videoView: VideoView) {
141-
//
142-
}
143-
144-
public func track(_ track: VideoTrack, didDetach videoView: VideoView) {
145-
//
146-
}
147-
148136
public func track(_ track: Track, didUpdate muted: Bool, shouldSendSignal: Bool) {
149137

150138
log("muted: \(muted) shouldSendSignal: \(shouldSendSignal)")

Sources/LiveKit/Support/MulticastDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public protocol MulticastDelegateCapable {
104104
var delegates: MulticastDelegate<DelegateType> { get }
105105
func add(delegate: DelegateType)
106106
func remove(delegate: DelegateType)
107-
func notify(_ fnc: @escaping (DelegateType) throws -> Void) rethrows
107+
func notify(_ fnc: @escaping (DelegateType) -> Void)
108108
}
109109

110110
extension MulticastDelegateCapable {

Sources/LiveKit/SwiftUI/SwiftUIVideoView.swift

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,18 @@
1616

1717
import SwiftUI
1818

19-
/// This class receives delegate events since a struct can't be used for a delegate
20-
class SwiftUIVideoViewDelegateReceiver: TrackDelegate, Loggable {
19+
/// This class receives ``TrackDelegate`` events since a struct can't be used for a delegate
20+
internal class TrackDelegateReceiver: TrackDelegate, Loggable {
2121

2222
@Binding var dimensions: Dimensions?
2323
@Binding var stats: TrackStats?
2424

25-
init(dimensions: Binding<Dimensions?> = .constant(nil),
26-
stats: Binding<TrackStats?> = .constant(nil)) {
25+
init(dimensions: Binding<Dimensions?>, stats: Binding<TrackStats?>) {
2726
self._dimensions = dimensions
2827
self._stats = stats
2928
}
3029

31-
func track(_ track: VideoTrack,
32-
didUpdate dimensions: Dimensions?) {
30+
func track(_ track: VideoTrack, didUpdate dimensions: Dimensions?) {
3331
DispatchQueue.main.async {
3432
self.dimensions = dimensions
3533
}
@@ -42,6 +40,22 @@ class SwiftUIVideoViewDelegateReceiver: TrackDelegate, Loggable {
4240
}
4341
}
4442

43+
/// This class receives ``VideoViewDelegate`` events since a struct can't be used for a delegate
44+
internal class VideoViewDelegateReceiver: VideoViewDelegate, Loggable {
45+
46+
@Binding var isRendering: Bool
47+
48+
init(isRendering: Binding<Bool>) {
49+
self._isRendering = isRendering
50+
}
51+
52+
func videoView(_ videoView: VideoView, didUpdate isRendering: Bool) {
53+
DispatchQueue.main.async {
54+
self.isRendering = isRendering
55+
}
56+
}
57+
}
58+
4559
/// A ``VideoView`` that can be used in SwiftUI.
4660
/// Supports both iOS and macOS.
4761
public struct SwiftUIVideoView: NativeViewRepresentable {
@@ -54,25 +68,32 @@ public struct SwiftUIVideoView: NativeViewRepresentable {
5468
let mirrorMode: VideoView.MirrorMode
5569
let debugMode: Bool
5670

71+
@Binding var isRendering: Bool
5772
@Binding var dimensions: Dimensions?
5873

59-
let delegateReceiver: SwiftUIVideoViewDelegateReceiver
74+
let trackDelegateReceiver: TrackDelegateReceiver
75+
let videoViewDelegateReceiver: VideoViewDelegateReceiver
6076

6177
public init(_ track: VideoTrack,
6278
layoutMode: VideoView.LayoutMode = .fill,
6379
mirrorMode: VideoView.MirrorMode = .auto,
6480
debugMode: Bool = false,
81+
isRendering: Binding<Bool> = .constant(false),
6582
dimensions: Binding<Dimensions?> = .constant(nil),
6683
trackStats: Binding<TrackStats?> = .constant(nil)) {
6784

6885
self.track = track
6986
self.layoutMode = layoutMode
7087
self.mirrorMode = mirrorMode
7188
self.debugMode = debugMode
89+
90+
self._isRendering = isRendering
7291
self._dimensions = dimensions
7392

74-
self.delegateReceiver = SwiftUIVideoViewDelegateReceiver(dimensions: dimensions,
75-
stats: trackStats)
93+
self.trackDelegateReceiver = TrackDelegateReceiver(dimensions: dimensions,
94+
stats: trackStats)
95+
96+
self.videoViewDelegateReceiver = VideoViewDelegateReceiver(isRendering: isRendering)
7697

7798
// update binding value
7899
DispatchQueue.main.async {
@@ -81,11 +102,12 @@ public struct SwiftUIVideoView: NativeViewRepresentable {
81102
}
82103

83104
// listen for TrackDelegate
84-
track.add(delegate: delegateReceiver)
105+
track.add(delegate: trackDelegateReceiver)
85106
}
86107

87108
public func makeView(context: Context) -> VideoView {
88109
let view = VideoView()
110+
view.add(delegate: videoViewDelegateReceiver)
89111
updateView(view, context: context)
90112
return view
91113
}
@@ -95,6 +117,11 @@ public struct SwiftUIVideoView: NativeViewRepresentable {
95117
videoView.layoutMode = layoutMode
96118
videoView.mirrorMode = mirrorMode
97119
videoView.debugMode = debugMode
120+
121+
// update
122+
DispatchQueue.main.async {
123+
self.isRendering = videoView.isRendering
124+
}
98125
}
99126

100127
public static func dismantleView(_ videoView: VideoView, coordinator: ()) {

Sources/LiveKit/Views/VideoView.swift

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,14 @@ import MetalKit
2121
/// A ``NativeViewType`` that conforms to ``RTCVideoRenderer``.
2222
public typealias NativeRendererView = NativeViewType & RTCVideoRenderer
2323

24-
public class VideoView: NativeView, Loggable {
24+
public class VideoView: NativeView, MulticastDelegateCapable, Loggable {
2525

26-
private static let mirrorTransform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
26+
public var delegates = MulticastDelegate<DelegateType>()
2727

28-
/// A set of bool values describing the state of rendering.
29-
public struct RenderState: OptionSet {
30-
public let rawValue: Int
31-
public init(rawValue: Int) {
32-
self.rawValue = rawValue
33-
}
28+
public typealias DelegateType = VideoViewDelegate
3429

35-
/// Received first frame and already rendered to the ``VideoView``.
36-
/// This can be used to trigger smooth transition of the UI.
37-
static let didRenderFirstFrame = RenderState(rawValue: 1 << 0)
38-
/// ``VideoView`` skipped rendering of a frame that could lead to crashes.
39-
static let didSkipUnsafeFrame = RenderState(rawValue: 1 << 1)
40-
}
30+
private static let mirrorTransform = CATransform3DMakeScale(-1.0, 1.0, 1.0)
31+
private static let _freezeDetectThreshold = 2.0
4132

4233
public enum LayoutMode: String, Codable, CaseIterable {
4334
case fit
@@ -69,7 +60,9 @@ public class VideoView: NativeView, Loggable {
6960
_state.mutate {
7061
// reset states if track updated
7162
if !Self.track($0.track, isEqualWith: newValue) {
72-
$0.renderState = []
63+
$0.renderDate = nil
64+
$0.didRenderFirstFrame = false
65+
$0.isRendering = false
7366
$0.rendererSize = nil
7467
}
7568
$0.track = newValue
@@ -96,6 +89,9 @@ public class VideoView: NativeView, Loggable {
9689
set { _state.mutate { $0.debugMode = newValue } }
9790
}
9891

92+
public var isRendering: Bool { _state.isRendering }
93+
public var didRenderFirstFrame: Bool { _state.didRenderFirstFrame }
94+
9995
private var nativeRenderer: NativeRendererView?
10096

10197
private var _debugTextView: TextView?
@@ -113,15 +109,21 @@ public class VideoView: NativeView, Loggable {
113109
var rendererSize: CGSize?
114110
var didLayout: Bool = false
115111
var layoutMode: LayoutMode = .fill
116-
117112
var mirrorMode: MirrorMode = .auto
118-
var renderState = RenderState()
119113

120114
var debugMode: Bool = false
115+
116+
// render states
117+
var renderDate: Date?
118+
var didRenderFirstFrame: Bool = false
119+
var isRendering: Bool = false
121120
}
122121

123122
internal var _state: StateSync<State>
124123

124+
// used for stats timer
125+
private lazy var _renderTimer = DispatchQueueTimer(timeInterval: 0.1)
126+
125127
public override init(frame: CGRect = .zero) {
126128

127129
// should always be on main thread
@@ -203,14 +205,23 @@ public class VideoView: NativeView, Loggable {
203205
}
204206
}
205207

206-
// renderState updated
207-
if state.renderState != oldState.renderState, let track = state.track {
208-
track.notify { $0.track(track, videoView: self, didUpdate: state.renderState) }
208+
// isRendering updated
209+
if state.isRendering != oldState.isRendering {
210+
211+
self.log("isRendering \(oldState.isRendering) -> \(state.isRendering)")
212+
213+
if state.isRendering {
214+
self._renderTimer.restart()
215+
} else {
216+
self._renderTimer.suspend()
217+
}
218+
219+
self.notify { $0.videoView(self, didUpdate: state.isRendering) }
209220
}
210221

211222
// viewSize updated
212-
if state.viewSize != oldState.viewSize, let track = state.track {
213-
track.notify { $0.track(track, videoView: self, didUpdate: state.viewSize) }
223+
if state.viewSize != oldState.viewSize {
224+
self.notify { $0.videoView(self, didUpdate: state.viewSize) }
214225
}
215226

216227
// toggle MTKView's isPaused property
@@ -222,6 +233,7 @@ public class VideoView: NativeView, Loggable {
222233
if state.debugMode != oldState.debugMode ||
223234
state.layoutMode != oldState.layoutMode ||
224235
state.mirrorMode != oldState.mirrorMode ||
236+
state.didRenderFirstFrame != oldState.didRenderFirstFrame ||
225237
shouldRenderDidUpdate || trackDidUpdate {
226238

227239
// must be on main
@@ -230,6 +242,18 @@ public class VideoView: NativeView, Loggable {
230242
}
231243
}
232244
}
245+
246+
_renderTimer.handler = { [weak self] in
247+
248+
guard let self = self else { return }
249+
250+
if self._state.isRendering, let renderDate = self._state.renderDate {
251+
let diff = Date().timeIntervalSince(renderDate)
252+
if diff >= Self._freezeDetectThreshold {
253+
self._state.mutate { $0.isRendering = false }
254+
}
255+
}
256+
}
233257
}
234258

235259
required init?(coder: NSCoder) {
@@ -261,11 +285,12 @@ public class VideoView: NativeView, Loggable {
261285
if _state.debugMode {
262286
let _trackSid = _state.track?.sid ?? "nil"
263287
let _dimensions = _state.track?.dimensions ?? .zero
264-
let _didRenderFirstFrame = _state.renderState.contains(.didRenderFirstFrame) ? "true" : "false"
288+
let _didRenderFirstFrame = _state.didRenderFirstFrame ? "true" : "false"
289+
let _isRendering = _state.isRendering ? "true" : "false"
265290
let _viewCount = _state.track?.videoViews.count ?? 0
266291
let _didLayout = _state.didLayout
267292
let debugView = ensureDebugTextView()
268-
debugView.text = "#\(hashValue)\n" + "\(_trackSid)\n" + "\(_dimensions.width)x\(_dimensions.height)\n" + "enabled: \(isEnabled)\n" + "firstFrame: \(_didRenderFirstFrame)\n" + "viewCount: \(_viewCount)\n" + "layout: \(_didLayout)"
293+
debugView.text = "#\(hashValue)\n" + "\(_trackSid)\n" + "\(_dimensions.width)x\(_dimensions.height)\n" + "enabled: \(isEnabled)\n" + "firstFrame: \(_didRenderFirstFrame)\n" + "isRendering: \(_isRendering)\n" + "viewCount: \(_viewCount)\n" + "layout: \(_didLayout)"
269294
debugView.frame = bounds
270295
#if os(iOS)
271296
debugView.layer.borderColor = (_state.shouldRender ? UIColor.green : UIColor.red).withAlphaComponent(0.5).cgColor
@@ -456,10 +481,10 @@ extension VideoView: RTCVideoRenderer {
456481
// cache last rendered frame
457482
track?.set(videoFrame: frame)
458483

459-
if !_state.renderState.contains(.didRenderFirstFrame) {
460-
_state.mutate { $0.renderState.insert(.didRenderFirstFrame) }
461-
self.log("did render first frame, track: \(String(describing: track))")
462-
_needsLayout = true
484+
_state.mutateAsync {
485+
$0.didRenderFirstFrame = true
486+
$0.isRendering = true
487+
$0.renderDate = Date()
463488
}
464489
}
465490
}

0 commit comments

Comments
 (0)