Skip to content

Commit 4821466

Browse files
authored
Pinch to zoom in & out (#384)
New VideoView.pinchToZoom and pinchToZoomAutoZoomOut properties which work for iOS only. Seamless zoom in/out will work for supported devices such as .builtInTripleCamera. This is currently only for local tracks (capturing). https://github.com/livekit/client-sdk-swift/assets/548776/2bc5fabb-3184-4613-9d3e-c8a236a15189
1 parent 7dd9a43 commit 4821466

File tree

5 files changed

+122
-27
lines changed

5 files changed

+122
-27
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ let package = Package(
1818
],
1919
dependencies: [
2020
// LK-Prefixed Dynamic WebRTC XCFramework
21-
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "114.5735.16"),
21+
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "114.5735.18"),
2222
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.26.0"),
2323
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"),
2424
// Only used for DocC generation
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2024 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 AVFoundation
18+
19+
public extension AVCaptureDevice {
20+
/// Helper extension to return the acual direction the camera is facing.
21+
/// In macOS, the Facetime camera's position is .unspecified but this property will return .front for such cases.
22+
var facingPosition: AVCaptureDevice.Position {
23+
if deviceType == .builtInWideAngleCamera, position == .unspecified {
24+
return .front
25+
}
26+
27+
return position
28+
}
29+
}
30+
31+
public extension Collection where Element: AVCaptureDevice {
32+
/// Helper extension to return only a single suggested device for each position.
33+
func singleDeviceforEachPosition() -> [AVCaptureDevice] {
34+
let front = first { $0.facingPosition == .front }
35+
let back = first { $0.facingPosition == .back }
36+
return [front, back].compactMap { $0 }
37+
}
38+
}

Sources/LiveKit/Support/DeviceManager.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,25 +28,26 @@ class DeviceManager: Loggable {
2828
}
2929

3030
// Async version, waits until inital device fetch is complete
31-
public func devices(types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera]) async throws -> [AVCaptureDevice] {
32-
try await devicesCompleter.wait().filter { types.contains($0.deviceType) }
31+
public func devices() async throws -> [AVCaptureDevice] {
32+
try await devicesCompleter.wait()
3333
}
3434

3535
// Sync version
36-
public func devices(types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera]) -> [AVCaptureDevice] {
37-
_state.devices.filter { types.contains($0.deviceType) }
36+
public func devices() -> [AVCaptureDevice] {
37+
_state.devices
3838
}
3939

4040
private lazy var discoverySession: AVCaptureDevice.DiscoverySession = {
4141
var deviceTypes: [AVCaptureDevice.DeviceType]
4242
#if os(iOS)
43+
// In order of priority
4344
deviceTypes = [
44-
.builtInWideAngleCamera, // General purpose use
45-
.builtInTelephotoCamera,
46-
.builtInUltraWideCamera,
47-
.builtInTripleCamera,
48-
.builtInDualCamera,
49-
.builtInDualWideCamera,
45+
.builtInTripleCamera, // Virtual, switchOver: [2, 6], default: 2
46+
.builtInDualCamera, // Virtual, switchOver: [3], default: 1
47+
.builtInDualWideCamera, // Virtual, switchOver: [2], default: 2
48+
.builtInWideAngleCamera, // Physical, General purpose use
49+
.builtInTelephotoCamera, // Physical
50+
.builtInUltraWideCamera, // Physical
5051
]
5152
// Xcode 15.0 Swift 5.9 (iOS 17)
5253
#if compiler(>=5.9)

Sources/LiveKit/Track/Capturers/CameraCapturer.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ public class CameraCapturer: VideoCapturer {
3434
public var options: CameraCaptureOptions { _cameraCapturerState.options }
3535

3636
@objc
37-
public static func captureDevices(types: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera]) async throws -> [AVCaptureDevice] {
38-
try await DeviceManager.shared.devices(types: types)
37+
public static func captureDevices() async throws -> [AVCaptureDevice] {
38+
try await DeviceManager.shared.devices()
3939
}
4040

4141
/// Checks whether both front and back capturing devices exist, and can be switched.
@@ -80,7 +80,7 @@ public class CameraCapturer: VideoCapturer {
8080
var device: AVCaptureDevice?
8181
}
8282

83-
private var _cameraCapturerState: StateSync<State>
83+
var _cameraCapturerState: StateSync<State>
8484

8585
// Used to hide LKRTCVideoCapturerDelegate symbol
8686
private lazy var adapter: VideoCapturerDelegateAdapter = .init(cameraCapturer: self)

Sources/LiveKit/Views/VideoView.swift

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ public class VideoView: NativeView, Loggable {
140140
set { _state.mutate { $0.transitionDuration = newValue } }
141141
}
142142

143+
@objc
144+
public var isPinchToZoomEnabled: Bool {
145+
get { _state.isPinchToZoomEnabled }
146+
set { _state.mutate { $0.isPinchToZoomEnabled = newValue } }
147+
}
148+
149+
@objc
150+
public var isAutoZoomResetEnabled: Bool {
151+
get { _state.isAutoZoomResetEnabled }
152+
set { _state.mutate { $0.isAutoZoomResetEnabled = newValue } }
153+
}
154+
143155
@objc
144156
public var isDebugMode: Bool {
145157
get { _state.isDebugMode }
@@ -192,9 +204,12 @@ public class VideoView: NativeView, Loggable {
192204
var renderTarget: RenderTarget = .primary
193205
var isSwapping: Bool = false
194206
var remainingRenderCountBeforeSwap: Int = 0 // Number of frames to be rendered on secondary until swap is initiated
195-
var transitionMode: TransitionMode = .flip
207+
var transitionMode: TransitionMode = .crossDissolve
196208
var transitionDuration: TimeInterval = 0.3
197209

210+
var isPinchToZoomEnabled: Bool = false
211+
var isAutoZoomResetEnabled: Bool = true
212+
198213
// Only used for rendering local tracks
199214
var captureOptions: VideoCaptureOptions? = nil
200215
var captureDevice: AVCaptureDevice? = nil
@@ -219,6 +234,12 @@ public class VideoView: NativeView, Loggable {
219234
private var _currentFPS: Int = 0
220235
private var _frameCount: Int = 0
221236

237+
#if os(iOS) || os(visionOS)
238+
private lazy var _pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(_handlePinchGesture(_:)))
239+
// This should be thread safe so it's not required to be guarded by the lock
240+
private var _pinchStartZoomFactor: CGFloat = 0.0
241+
#endif
242+
222243
override public init(frame: CGRect = .zero) {
223244
// initial state
224245
_state = StateSync(State(viewSize: frame.size))
@@ -326,6 +347,15 @@ public class VideoView: NativeView, Loggable {
326347
}
327348
}
328349

350+
#if os(iOS) || os(visionOS)
351+
let newIsPinchToZoomEnabled = newState.isPinchToZoomEnabled
352+
if newIsPinchToZoomEnabled != oldState.isPinchToZoomEnabled {
353+
Task.detached { @MainActor in
354+
self._pinchGestureRecognizer.isEnabled = newIsPinchToZoomEnabled
355+
}
356+
}
357+
#endif
358+
329359
if newState.isDebugMode != oldState.isDebugMode {
330360
// fps timer
331361
if newState.isDebugMode {
@@ -359,8 +389,44 @@ public class VideoView: NativeView, Loggable {
359389

360390
await self._renderTimer.restart()
361391
}
392+
393+
#if os(iOS) || os(visionOS)
394+
// Add pinch gesture recognizer
395+
addGestureRecognizer(_pinchGestureRecognizer)
396+
_pinchGestureRecognizer.isEnabled = _state.isPinchToZoomEnabled
397+
#endif
362398
}
363399

400+
#if os(iOS) || os(visionOS)
401+
@objc func _handlePinchGesture(_ sender: UIPinchGestureRecognizer) {
402+
if let track = _state.track as? LocalVideoTrack,
403+
let capturer = track.capturer as? CameraCapturer,
404+
let device = capturer.device
405+
{
406+
if sender.state == .began {
407+
_pinchStartZoomFactor = device.videoZoomFactor
408+
} else {
409+
do {
410+
try device.lockForConfiguration()
411+
defer { device.unlockForConfiguration() }
412+
413+
if sender.state == .changed {
414+
let minZoom = device.minAvailableVideoZoomFactor
415+
let maxZoom = device.maxAvailableVideoZoomFactor
416+
device.videoZoomFactor = (_pinchStartZoomFactor * sender.scale).clamped(to: minZoom ... maxZoom)
417+
} else if sender.state == .ended || sender.state == .cancelled, _state.isAutoZoomResetEnabled {
418+
// Zoom to default zoom factor
419+
let defaultZoomFactor = LKRTCCameraVideoCapturer.defaultZoomFactor(forDeviceType: device.deviceType)
420+
device.ramp(toVideoZoomFactor: defaultZoomFactor, withRate: 32.0)
421+
}
422+
} catch {
423+
log("Failed to adjust videoZoomFactor", .warning)
424+
}
425+
}
426+
}
427+
}
428+
#endif
429+
364430
@available(*, unavailable)
365431
required init?(coder _: NSCoder) {
366432
fatalError("init(coder:) has not been implemented")
@@ -527,7 +593,7 @@ private extension VideoView {
527593

528594
func _shouldMirror() -> Bool {
529595
switch _state.mirrorMode {
530-
case .auto: return _state.captureDevice?.realPosition == .front
596+
case .auto: return _state.captureDevice?.facingPosition == .front
531597
case .off: return false
532598
case .mirror: return true
533599
}
@@ -661,7 +727,7 @@ extension VideoView: VideoRenderer {
661727

662728
// Currently only for iOS
663729
#if os(iOS)
664-
let (mode, duration, position) = _state.read { ($0.transitionMode, $0.transitionDuration, $0.captureDevice?.realPosition) }
730+
let (mode, duration, position) = _state.read { ($0.transitionMode, $0.transitionDuration, $0.captureDevice?.facingPosition) }
665731
if let transitionOption = mode.toAnimationOption(fromPosition: position) {
666732
UIView.transition(with: self, duration: duration, options: transitionOption, animations: block, completion: nil)
667733
} else {
@@ -801,16 +867,6 @@ private extension VideoView {
801867
}
802868
}
803869

804-
extension AVCaptureDevice {
805-
var realPosition: AVCaptureDevice.Position {
806-
if deviceType == .builtInWideAngleCamera, position == .unspecified {
807-
return .front
808-
}
809-
810-
return position
811-
}
812-
}
813-
814870
#if os(iOS)
815871
extension VideoView.TransitionMode {
816872
func toAnimationOption(fromPosition position: AVCaptureDevice.Position? = nil) -> UIView.AnimationOptions? {

0 commit comments

Comments
 (0)