Skip to content

Commit 05b6ced

Browse files
VideoView flickering (#769)
Resolves #722 - Fixes `VideoRendererAdapter` never actually removed from the RTC track, causing multiple tracks concurrently rendering into the same view - The root cause is tricks like `override func isEqual(_ object: Any?)` won't work (anymore?) on the Obj-C++ side as it uses `==` directly; it's also pretty fragile - `uikit-minimal` was a good example as the number of adapters exploded after reusing cells - Reproduction steps: - create multiple cells in `uikit-minimal` - multiple participants (some of them can be muted) - shuffle - observe cell behavior and FPS - Moves the side effects a bit, as forcing them to be synchronous: - was kinda unexpected (and unnecessary) when running from the main actor `Handle track removal outside of main queue` - can lead to deadlocks in practice <img width="278" height="114" alt="Screenshot 2025-08-28 at 1 37 53 PM" src="https://github.com/user-attachments/assets/281a367d-bfeb-4422-b1fa-99cda94e299f" /> --------- Co-authored-by: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com>
1 parent d4c1370 commit 05b6ced

8 files changed

Lines changed: 37 additions & 83 deletions

File tree

.changes/video-view-lifecycle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Renderer lifecycle in the video view causing flickering effect"

Sources/LiveKit/Extensions/Sendable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ extension LKRTCDefaultAudioProcessingModule: @unchecked Swift.Sendable {}
4848
// MARK: Collections
4949

5050
extension NSHashTable: @unchecked Swift.Sendable {} // cannot specify Obj-C generics
51+
extension NSMapTable: @unchecked Swift.Sendable {} // cannot specify Obj-C generics
5152
#if swift(<6.2)
5253
extension Dictionary: Swift.Sendable where Key: Sendable, Value: Sendable {}
5354
#endif

Sources/LiveKit/Protocols/VideoRenderer.swift

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,32 +42,20 @@ public protocol VideoRenderer: Sendable {
4242
nonisolated func render(frame: VideoFrame, captureDevice: AVCaptureDevice?, captureOptions: VideoCaptureOptions?)
4343
}
4444

45-
class VideoRendererAdapter: NSObject, LKRTCVideoRenderer {
46-
private weak var target: VideoRenderer?
45+
final class VideoRendererAdapter: NSObject, LKRTCVideoRenderer {
46+
weak var renderer: VideoRenderer?
4747

48-
init(target: VideoRenderer) {
49-
self.target = target
48+
init(renderer: VideoRenderer) {
49+
self.renderer = renderer
5050
}
5151

5252
func setSize(_ size: CGSize) {
53-
target?.set?(size: size)
53+
renderer?.set?(size: size)
5454
}
5555

5656
func renderFrame(_ frame: LKRTCVideoFrame?) {
5757
guard let frame = frame?.toLKType() else { return }
58-
target?.render?(frame: frame)
59-
target?.render?(frame: frame, captureDevice: nil, captureOptions: nil)
60-
}
61-
62-
// Proxy the equality operators
63-
64-
override func isEqual(_ object: Any?) -> Bool {
65-
guard let other = object as? VideoRendererAdapter else { return false }
66-
return target === other.target
67-
}
68-
69-
override var hash: Int {
70-
guard let target else { return 0 }
71-
return ObjectIdentifier(target).hashValue
58+
renderer?.render?(frame: frame)
59+
renderer?.render?(frame: frame, captureDevice: nil, captureOptions: nil)
7260
}
7361
}

Sources/LiveKit/Track/Remote/RemoteVideoTrack.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,13 @@ extension RemoteVideoTrack: VideoTrack {
4040
return
4141
}
4242

43+
let adapter = VideoRendererAdapter(renderer: videoRenderer)
44+
4345
_state.mutate {
44-
$0.videoRenderers.add(videoRenderer)
46+
$0.videoRendererAdapters.setObject(adapter, forKey: videoRenderer)
4547
}
4648

47-
rtcVideoTrack.add(VideoRendererAdapter(target: videoRenderer))
49+
rtcVideoTrack.add(adapter)
4850
}
4951

5052
public func remove(videoRenderer: VideoRenderer) {
@@ -53,10 +55,17 @@ extension RemoteVideoTrack: VideoTrack {
5355
return
5456
}
5557

56-
_state.mutate {
57-
$0.videoRenderers.remove(videoRenderer)
58+
let adapter = _state.mutate {
59+
let adapter = $0.videoRendererAdapters.object(forKey: videoRenderer)
60+
$0.videoRendererAdapters.removeObject(forKey: videoRenderer)
61+
return adapter
62+
}
63+
64+
guard let adapter else {
65+
log("No adapter found for videoRenderer", .warning)
66+
return
5867
}
5968

60-
rtcVideoTrack.remove(VideoRendererAdapter(target: videoRenderer))
69+
rtcVideoTrack.remove(adapter)
6170
}
6271
}

Sources/LiveKit/Track/Track.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ public class Track: NSObject, @unchecked Sendable, Loggable {
127127
var rtpSenderForCodec: [VideoCodec: LKRTCRtpSender] = [:] // simulcastSender
128128
var rtpReceiver: LKRTCRtpReceiver?
129129

130-
// Weak reference to all VideoRenderers attached to this track.
131-
var videoRenderers = NSHashTable<VideoRenderer>.weakObjects()
130+
// All VideoRendererAdapters attached to this track, key/value for direct removal.
131+
var videoRendererAdapters = NSMapTable<VideoRenderer, VideoRendererAdapter>.weakToStrongObjects()
132132
}
133133

134134
let _state: StateSync<State>

Sources/LiveKit/TrackPublications/RemoteTrackPublication.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ extension RemoteTrackPublication {
340340
return
341341
}
342342

343-
let videoRenderers = track?._state.videoRenderers.allObjects ?? []
343+
let videoRenderers = track?._state.videoRendererAdapters.objectEnumerator()?.allObjects.compactMap { ($0 as? VideoRendererAdapter)?.renderer } ?? []
344344
let isEnabled = videoRenderers.containsOneOrMoreAdaptiveStreamEnabledRenderers()
345345
var dimensions: Dimensions = .zero
346346

Sources/LiveKit/Views/VideoView.swift

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -273,16 +273,21 @@ public class VideoView: NativeView, Loggable {
273273
let renderModeDidUpdate = newState.renderMode != oldState.renderMode
274274
let trackDidUpdate = !Self.track(oldState.track as? VideoTrack, isEqualWith: newState.track as? VideoTrack)
275275

276+
// Always add/remove from the track asynchronously - even when called on @MainActor
276277
if trackDidUpdate || shouldRenderDidUpdate {
277-
// Handle track removal outside of main queue
278-
if let track = oldState.track as? VideoTrack {
279-
track.remove(videoRenderer: self)
278+
Task {
279+
if let track = oldState.track as? VideoTrack {
280+
track.remove(videoRenderer: self)
281+
}
282+
if let track = newState.track as? VideoTrack, newState.shouldRender {
283+
track.add(videoRenderer: self)
284+
}
280285
}
281286
}
282287

283-
// Enter .main only if UI updates are required
288+
// Recreate renderers if necessary on @MainActor
284289
if trackDidUpdate || shouldRenderDidUpdate || renderModeDidUpdate {
285-
mainSyncOrAsync { @MainActor in
290+
Task { @MainActor in
286291
var didReCreateNativeRenderer = false
287292

288293
if trackDidUpdate || shouldRenderDidUpdate {
@@ -316,13 +321,6 @@ public class VideoView: NativeView, Loggable {
316321
}
317322
}
318323

319-
// Handle track addition outside of main queue
320-
if trackDidUpdate || shouldRenderDidUpdate {
321-
if let track = newState.track as? VideoTrack, newState.shouldRender {
322-
track.add(videoRenderer: self)
323-
}
324-
}
325-
326324
// isRendering updated
327325
if newState.isRendering != oldState.isRendering {
328326
log("isRendering \(oldState.isRendering) -> \(newState.isRendering)")
@@ -439,7 +437,7 @@ public class VideoView: NativeView, Loggable {
439437
let _didRenderFirstFrame = state.didRenderFirstFrame ? "true" : "false"
440438
let _isRendering = state.isRendering ? "true" : "false"
441439
let _renderMode = String(describing: state.renderMode)
442-
let _viewCount = state.track?._state.videoRenderers.allObjects.count ?? 0
440+
let _viewCount = state.track?._state.videoRendererAdapters.count ?? 0
443441
let debugView = ensureDebugTextView()
444442
debugView.text = "#\(hashValue)\n" + "\(String(describing: _trackSid))\n" + "\(_dimensions.width)x\(_dimensions.height)\n" + "isEnabled: \(isEnabled)\n" + "firstFrame: \(_didRenderFirstFrame)\n" + "isRendering: \(_isRendering)\n" + "renderMode: \(_renderMode)\n" + "viewCount: \(_viewCount)\n" + "FPS: \(_currentFPS)\n"
445443
debugView.frame = bounds
@@ -858,18 +856,6 @@ extension LKRTCMTLVideoView: Mirrorable {
858856
}
859857
#endif
860858

861-
private extension VideoView {
862-
nonisolated func mainSyncOrAsync(operation: @MainActor @escaping () -> Void) {
863-
if Thread.current.isMainThread {
864-
MainActor.assumeIsolated(operation)
865-
} else {
866-
Task { @MainActor in
867-
operation()
868-
}
869-
}
870-
}
871-
}
872-
873859
#if os(iOS)
874860
extension VideoView.TransitionMode {
875861
func toAnimationOption(fromPosition position: AVCaptureDevice.Position? = nil) -> UIView.AnimationOptions? {

Tests/LiveKitTests/VideoViewTests.swift

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)