Fix build: TileVideoView composes VideoView (can't subclass a non-open class)

Build error: `cannot inherit from non-open class 'VideoView' outside of its
defining module` — VideoView is public, not open, so it can't be subclassed
in the plugin module. Reworked TileVideoView from a VideoView subclass into a
UIView CONTAINER that holds a VideoView: the container is frame-synced to the
web tile rect, the pinch/pan zoom transform lives on the inner video (so it
never fights the position poll), and the name/mute overlays sit on the
container so they no longer scale with zoom. track/layoutMode are forwarded.

Also silenced the extension warning: SampleHandler now restates
`@unchecked Sendable`.

The broadcast extension itself compiled + linked in the failed build, so the
SPM-linked extension injection + App Group setup are working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 10:25:46 +05:30
parent 27c582bceb
commit a561852067
2 changed files with 40 additions and 18 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ import LiveKit
// convention, keyed off this extension's bundle id (com.bizgaze.connect.broadcast) and the default app group
// group.<appBundleId>. Both are also set explicitly on the app side (RTCScreenSharingExtension /
// RTCAppGroupIdentifier in Info.plist) so there's no ambiguity.
class SampleHandler: LKSampleHandler {}
final class SampleHandler: LKSampleHandler, @unchecked Sendable {}
@@ -171,15 +171,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private static let nameTag = 9001
private static let muteTag = 9002
// Build a tile VideoView with its native name label (bottom-left) + mute badge (bottom-right) the video
// covers the web tile, so these redraw the essentials the web can no longer show through.
// Build a tile view (a container holding a LiveKit VideoView) with its native name label (bottom-left) +
// mute badge (top-left) the video covers the web tile, so these redraw the essentials.
private func makeTileView(host: UIView, key: String) -> TileVideoView {
let v = TileVideoView()
v.layoutMode = .fill
v.backgroundColor = .black
v.clipsToBounds = true
v.layer.cornerRadius = 8
v.installZoomGestures()
let v = TileVideoView(frame: .zero) // sets up its inner VideoView + zoom gestures + cosmetics in init
host.addSubview(v)
// Name chip (bottom-left): white text on a dark translucent pill so it's legible over ANY video
@@ -388,7 +383,9 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
vv.isScreen = wantScreen // enables pinch-zoom on shared screens (resets zoom if turned off)
vv.layoutMode = wantScreen ? .fit : .fill
if vv.track !== track { vv.track = track }
if vv.transform.isIdentity { vv.frame = CGRect(x: x, y: y, width: w, height: h) } // don't fight an active zoom
// The container always tracks the tile rect; the zoom transform lives on the INNER video, so
// this never fights an active zoom.
vv.frame = CGRect(x: x, y: y, width: w, height: h)
// Native video covers the web tile, so redraw the essentials (name + muted) natively.
self.updateTileOverlay(vv, name: (t["name"] as? String) ?? "", muted: (t["muted"] as? Bool) ?? false)
}
@@ -549,16 +546,41 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
}
}
// A tile video view that supports pinch-to-zoom + pan while it's showing a shared SCREEN (so fine print is
// legible); double-tap resets. Camera tiles keep isScreen=false and never zoom. While zoomed the view carries
// a transform, and syncVideoTiles stops overwriting its frame so the zoom isn't fought each poll.
final class TileVideoView: VideoView, UIGestureRecognizerDelegate {
// A tile view = a container holding a LiveKit VideoView. We CANNOT subclass VideoView (it's `public`, not
// `open`, so subclassing outside its module is illegal), so we compose instead. Pinch-to-zoom + pan apply to
// the INNER video while it's showing a shared SCREEN (so fine print is legible); double-tap resets. The
// container itself stays frame-synced to the web tile rect, so zooming never fights the position poll, and the
// name/mute overlays (added to the container by makeTileView) don't scale with the zoom.
final class TileVideoView: UIView, UIGestureRecognizerDelegate {
let video = VideoView()
var isScreen = false { didSet { if !isScreen { resetZoom() } } }
var isZoomed: Bool { !transform.isIdentity }
var isZoomed: Bool { !video.transform.isIdentity }
private var zoomScale: CGFloat = 1
private var zoomOffset: CGPoint = .zero
func installZoomGestures() {
// Forward the two properties the plugin sets so call sites read like a VideoView.
var track: VideoTrack? { get { video.track } set { video.track = newValue } }
var layoutMode: VideoView.LayoutMode { get { video.layoutMode } set { video.layoutMode = newValue } }
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
clipsToBounds = true
layer.cornerRadius = 8
video.layoutMode = .fill
addSubview(video)
installZoomGestures()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// Position via bounds+center (not frame) so it coexists with the zoom transform.
override func layoutSubviews() {
super.layoutSubviews()
video.bounds = CGRect(origin: .zero, size: bounds.size)
video.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
private func installZoomGestures() {
isUserInteractionEnabled = true
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(onPinch(_:))); pinch.delegate = self
let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))); pan.delegate = self
@@ -568,11 +590,11 @@ final class TileVideoView: VideoView, UIGestureRecognizerDelegate {
func resetZoom() {
zoomScale = 1; zoomOffset = .zero
if !transform.isIdentity { transform = .identity }
if !video.transform.isIdentity { video.transform = .identity }
}
private func apply() {
transform = CGAffineTransform(translationX: zoomOffset.x, y: zoomOffset.y).scaledBy(x: zoomScale, y: zoomScale)
video.transform = CGAffineTransform(translationX: zoomOffset.x, y: zoomOffset.y).scaledBy(x: zoomScale, y: zoomScale)
}
@objc private func onPinch(_ g: UIPinchGestureRecognizer) {