diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0b56de3..6c8030cd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ### 🔄 Changed +- You can now focus on a desired point in the local video stream. [#221](https://github.com/GetStream/stream-video-swift/pull/221) # [0.4.1](https://github.com/GetStream/stream-video-swift/releases/tag/0.4.1) _October 16, 2023_ diff --git a/DemoApp/Sources/Components/DemoAppViewFactory.swift b/DemoApp/Sources/Components/DemoAppViewFactory.swift index f9f6d0e0a..70b649caf 100644 --- a/DemoApp/Sources/Components/DemoAppViewFactory.swift +++ b/DemoApp/Sources/Components/DemoAppViewFactory.swift @@ -59,7 +59,7 @@ final class DemoAppViewFactory: ViewFactory { func makeCallTopView(viewModel: CallViewModel) -> DemoCallTopView { DemoCallTopView(viewModel: viewModel) } - + func makeVideoCallParticipantModifier( participant: CallParticipant, call: Call?, diff --git a/DemoApp/Sources/Views/LongPressToFocusViewModifier/LongPressToFocusViewModifier.swift b/DemoApp/Sources/Views/LongPressToFocusViewModifier/LongPressToFocusViewModifier.swift new file mode 100644 index 000000000..6466ae9f7 --- /dev/null +++ b/DemoApp/Sources/Views/LongPressToFocusViewModifier/LongPressToFocusViewModifier.swift @@ -0,0 +1,83 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import StreamVideo +import SwiftUI +import AVFoundation + +/// A `ViewModifier` that adds a long press to focus gesture to a SwiftUI view. +struct LongPressToFocusViewModifier: ViewModifier { + + /// The frame within which the focus gesture can be recognized. + var availableFrame: CGRect + + /// The handler to call with the focus point when the gesture is recognized. + var handler: (CGPoint) -> Void + + /// Modifies the content by adding a long press gesture recognizer. + /// + /// - Parameter content: The content to be modified. + /// - Returns: The modified content with the long press to focus gesture added. + func body(content: Content) -> some View { + content + .gesture( + // A long press gesture requiring a minimum of 0.5 seconds to be recognized. + LongPressGesture(minimumDuration: 0.5) + // Sequence the long press gesture with a drag gesture in the local coordinate space. + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local)) + .onEnded { value in + // Handle the end of the gesture sequence. + switch value { + // If the long press gesture was succeeded by a drag gesture. + case .second(true, let drag): + // If the drag gesture has a valid location. + if let location = drag?.location { + // Convert the point to the focus interest point and call the handler. + handler(convertToPointOfInterest(location)) + } + // All other cases do nothing. + default: + break + } + } + ) + } + + /// Converts a point within the view's coordinate space to a point of interest for camera focus. + /// + /// The conversion is based on the `availableFrame` property, flipping the axis + /// and normalizing the point to the range [0, 1]. + /// + /// - Parameter point: The point to convert. + /// - Returns: The converted point of interest. + func convertToPointOfInterest(_ point: CGPoint) -> CGPoint { + CGPoint( + x: point.y / availableFrame.height, + y: 1.0 - point.x / availableFrame.width + ) + } +} + +extension View { + + /// Adds a long press to focus gesture to the view. + /// + /// - Parameters: + /// - availableFrame: The frame within which the focus gesture can be recognized. + /// - handler: The closure to call with the focus point when the gesture is recognized. + /// - Returns: A modified view with the long press to focus gesture added. + @ViewBuilder + func longPressToFocus( + availableFrame: CGRect, + handler: @escaping (CGPoint) -> Void + ) -> some View { + // Apply the view modifier to add the gesture to the view. + modifier( + LongPressToFocusViewModifier( + availableFrame: availableFrame, + handler: handler + ) + ) + } +} diff --git a/DemoApp/Sources/Views/VideoCallParticipantModifier/DemoVideoCallParticipantModifier.swift b/DemoApp/Sources/Views/VideoCallParticipantModifier/DemoVideoCallParticipantModifier.swift index ba513a8a2..b2fa05d97 100644 --- a/DemoApp/Sources/Views/VideoCallParticipantModifier/DemoVideoCallParticipantModifier.swift +++ b/DemoApp/Sources/Views/VideoCallParticipantModifier/DemoVideoCallParticipantModifier.swift @@ -34,66 +34,23 @@ struct DemoVideoCallParticipantModifier: ViewModifier { func body(content: Content) -> some View { content - .adjustVideoFrame(to: availableFrame.size.width, ratio: ratio) - .overlay( - ZStack { - BottomView(content: { - HStack { - ParticipantInfoView( - participant: participant, - isPinned: participant.isPinned - ) - - if showAllInfo { - Spacer() - ConnectionQualityIndicator( - connectionQuality: participant.connectionQuality - ) - } - } - .padding(.bottom, 2) - }) - .padding(.all, showAllInfo ? 16 : 8) - - if participant.isSpeaking && participantCount > 1 { - Rectangle() - .strokeBorder(Color.blue.opacity(0.7), lineWidth: 2) - } - - if popoverShown { - ParticipantPopoverView( - participant: participant, - call: call, - popoverShown: $popoverShown - ) { - PopoverButton( - title: "Show stats", - popoverShown: $popoverShown) { - statsShown = true - } - } - } - - VStack(alignment: .center) { - Spacer() - if statsShown, let call { - ParticipantStatsView(call: call, participant: participant) - .padding(.bottom) - } - } - } + .modifier( + VideoCallParticipantModifier( + participant: participant, + call: call, + availableFrame: availableFrame, + ratio: ratio, + showAllInfo: showAllInfo) ) - .modifier(ReactionsViewModifier(participant: participant, availableSize: availableFrame.size)) - .onTapGesture(count: 2, perform: { - popoverShown = true - }) - .onTapGesture(count: 1) { - if popoverShown { - popoverShown = false - } - if statsShown { - statsShown = false - } + .modifier( + ReactionsViewModifier( + participant: participant, + availableSize: availableFrame.size + ) + ) + .longPressToFocus(availableFrame: availableFrame) { + guard call?.state.sessionId == participant.sessionId else { return } + try? call?.focus(at: $0) } } diff --git a/README.md b/README.md index 19ab7a4c8..b862af2d8 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ Video roadmap and changelog is available [here](https://github.com/GetStream/pro - [ ] Video UIKit tutorial - [ ] Improve logging / Sentry integration - [ ] Camera controls -- [ ] Tap to focus +- [x] Tap to focus - [ ] Complete reconnection flows - [ ] Complete Livestreaming APIs and Tutorials for hosting & watching - [ ] Dynascale 2.0 diff --git a/Sources/StreamVideo/Call.swift b/Sources/StreamVideo/Call.swift index 04ba827f4..53f3ee681 100644 --- a/Sources/StreamVideo/Call.swift +++ b/Sources/StreamVideo/Call.swift @@ -696,7 +696,25 @@ public class Call: @unchecked Sendable, WSEventsSubscriber { unpinRequest: unpinRequest ) } - + + /// Tries to focus the camera at the specified point within the view. + /// + /// The method delegates the focus action to the `callController`'s `focus(at:)` + /// method, which is expected to handle the camera focus logic. If an error occurs during the process, + /// it throws an exception. + /// + /// - Parameter point: A `CGPoint` value representing the location within the view where the + /// camera should focus. The point (0, 0) is at the top-left corner of the view, and the point (1, 1) is at + /// the bottom-right corner. + /// - Throws: An error if the focus operation cannot be completed. The type of error depends on + /// the underlying implementation in the `callController`. + /// + /// - Note: Ensure that the device supports tap to focus and that it is enabled before calling this + /// method. Otherwise, it might result in an error. + public func focus(at point: CGPoint) throws { + try callController.focus(at: point) + } + //MARK: - Internal internal func update(reconnectionStatus: ReconnectionStatus) { diff --git a/Sources/StreamVideo/Controllers/CallController.swift b/Sources/StreamVideo/Controllers/CallController.swift index 80cd63c48..5e2b4f5a4 100644 --- a/Sources/StreamVideo/Controllers/CallController.swift +++ b/Sources/StreamVideo/Controllers/CallController.swift @@ -185,7 +185,28 @@ class CallController { webRTCClient?.ownCapabilities = ownCapabilities } } - + + /// Initiates a focus operation at a specific point on the camera's view. + /// + /// This method attempts to focus the camera at the given point by calling the `focus(at:)` + /// method on the current WebRTC client. The focus point is specified as a `CGPoint` within the + /// coordinate space of the view. + /// + /// - Parameter point: A `CGPoint` value representing the location within the view where the + /// camera should attempt to focus. The coordinate space of the point is typically normalized to the + /// range [0, 1], where (0, 0) represents the top-left corner of the view, and (1, 1) represents the + /// bottom-right corner. + /// - Throws: An error if the focus operation cannot be completed. This might occur if there is no + /// current WebRTC client available, if the camera does not support tap to focus, or if an internal error + /// occurs within the WebRTC client. + /// + /// - Note: Before calling this method, ensure that the device's camera supports tap to focus + /// functionality and that the current WebRTC client is properly configured and connected. Otherwise, + /// the method may throw an error. + func focus(at point: CGPoint) throws { + try currentWebRTCClient().focus(at: point) + } + /// Cleans up the call controller. func cleanUp() { guard call != nil else { return } diff --git a/Sources/StreamVideo/WebRTC/VideoCapturer.swift b/Sources/StreamVideo/WebRTC/VideoCapturer.swift index 6185ff331..7567d3dbe 100644 --- a/Sources/StreamVideo/WebRTC/VideoCapturer.swift +++ b/Sources/StreamVideo/WebRTC/VideoCapturer.swift @@ -96,7 +96,61 @@ class VideoCapturer: CameraVideoCapturing { } } } - + + /// Initiates a focus and exposure operation at the specified point on the camera's view. + /// + /// This method attempts to focus the camera and set the exposure at a specific point by interacting + /// with the device's capture session. + /// It requires the `videoCapturer` property to be cast to `RTCCameraVideoCapturer`, and for + /// a valid `AVCaptureDeviceInput` to be accessible. + /// If these conditions are not met, it throws a `ClientError.Unexpected` error. + /// + /// - Parameter point: A `CGPoint` representing the location within the view where the camera + /// should adjust focus and exposure. + /// - Throws: A `ClientError.Unexpected` error if the necessary video capture components are + /// not available or properly configured. + /// + /// - Note: Ensure that the `point` is normalized to the camera's coordinate space, ranging + /// from (0,0) at the top-left to (1,1) at the bottom-right. + func focus(at point: CGPoint) throws { + guard + let captureSession = (videoCapturer as? RTCCameraVideoCapturer)?.captureSession, + let device = captureSession.inputs.first as? AVCaptureDeviceInput + else { + throw ClientError.Unexpected() + } + + do { + try device.device.lockForConfiguration() + + if device.device.isFocusPointOfInterestSupported { + log.debug("Will focus at point: \(point)") + device.device.focusPointOfInterest = point + + if device.device.isFocusModeSupported(.autoFocus) { + device.device.focusMode = .autoFocus + } else { + log.warning("There are no supported focusMode.") + } + + log.debug("Will set exposure at point: \(point)") + if device.device.isExposurePointOfInterestSupported { + device.device.exposurePointOfInterest = point + + if device.device.isExposureModeSupported(.autoExpose) { + device.device.exposureMode = .autoExpose + } else { + log.warning("There are no supported exposureMode.") + } + } + } + + device.device.unlockForConfiguration() + } catch { + log.error(error) + } + } + //MARK: - private private func checkForBackgroundCameraAccess() { diff --git a/Sources/StreamVideo/WebRTC/WebRTCClient.swift b/Sources/StreamVideo/WebRTC/WebRTCClient.swift index dcda02194..ec8185d55 100644 --- a/Sources/StreamVideo/WebRTC/WebRTCClient.swift +++ b/Sources/StreamVideo/WebRTC/WebRTCClient.swift @@ -547,7 +547,29 @@ class WebRTCClient: NSObject, @unchecked Sendable { datacenter: signalService.hostname ) } - + + /// Initiates a camera focus operation at the specified point. + /// + /// This method attempts to focus the camera at a specific point on the screen. + /// It requires the `videoCapturer` property to be properly cast to `VideoCapturer` type. + /// If the casting fails, it throws a `ClientError.Unexpected` error. + /// + /// - Parameter point: A `CGPoint` representing the location within the view where the camera + /// should focus. + /// - Throws: A `ClientError.Unexpected` error if `videoCapturer` cannot be cast to + /// `VideoCapturer`. + /// + /// - Note: The `point` parameter should be provided in the coordinate space of the view, where + /// (0,0) is the top-left corner, and (1,1) is the bottom-right corner. Make sure the camera supports + /// tap-to-focus functionality before invoking this method. + func focus(at point: CGPoint) throws { + guard let videoCapturer = videoCapturer as? VideoCapturer else { + throw ClientError.Unexpected() + } + + try videoCapturer.focus(at: point) + } + // MARK: - private private func handleOnSocketConnected() { diff --git a/Sources/StreamVideoSwiftUI/CallContainer.swift b/Sources/StreamVideoSwiftUI/CallContainer.swift index e10379dfb..139730fd0 100644 --- a/Sources/StreamVideoSwiftUI/CallContainer.swift +++ b/Sources/StreamVideoSwiftUI/CallContainer.swift @@ -108,20 +108,22 @@ public struct WaitingLocalUserView: View { public var body: some View { ZStack { - if let localParticipant = viewModel.localParticipant { - LocalVideoView( - viewFactory: viewFactory, - participant: localParticipant, - idSuffix: "waiting", - callSettings: viewModel.callSettings, - call: viewModel.call - ) - } else { - DefaultBackgroundGradient() - } - - VStack { - Spacer() + DefaultBackgroundGradient() + .edgesIgnoringSafeArea(.all) + + VStack() { + if let localParticipant = viewModel.localParticipant { + LocalVideoView( + viewFactory: viewFactory, + participant: localParticipant, + idSuffix: "waiting", + callSettings: viewModel.callSettings, + call: viewModel.call + ) + } else { + Spacer() + } + viewFactory.makeCallControlsView(viewModel: viewModel) .opacity(viewModel.callingState == .reconnecting ? 0 : 1) } diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 5c762b1b0..b4a7e5df6 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ 407D5D3D2ACEF0C500B5044E /* VisibilityThresholdModifier_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407D5D3C2ACEF0C500B5044E /* VisibilityThresholdModifier_Tests.swift */; }; 407F29FF2AA6011500C3EAF8 /* MemoryLogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */; }; 407F2A002AA6011B00C3EAF8 /* LogQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */; }; + 40901A612AE6A48C00B6831D /* LongPressToFocusViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40901A5E2AE693B100B6831D /* LongPressToFocusViewModifier.swift */; }; 4093861A2AA09E4A00FF5AF4 /* MemoryLogDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 409386192AA09E4A00FF5AF4 /* MemoryLogDestination.swift */; }; 4093861C2AA0A11500FF5AF4 /* LogQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */; }; 4093861F2AA0A21800FF5AF4 /* MemoryLogViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */; }; @@ -918,6 +919,7 @@ 406303412AD848000091AE77 /* CallParticipant_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallParticipant_Mock.swift; sourceTree = ""; }; 4067A5D72AE1249400CFDEB1 /* CornerClipper_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CornerClipper_Tests.swift; sourceTree = ""; }; 407D5D3C2ACEF0C500B5044E /* VisibilityThresholdModifier_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityThresholdModifier_Tests.swift; sourceTree = ""; }; + 40901A5E2AE693B100B6831D /* LongPressToFocusViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPressToFocusViewModifier.swift; sourceTree = ""; }; 409386192AA09E4A00FF5AF4 /* MemoryLogDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLogDestination.swift; sourceTree = ""; }; 4093861B2AA0A11500FF5AF4 /* LogQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogQueue.swift; sourceTree = ""; }; 4093861E2AA0A21800FF5AF4 /* MemoryLogViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLogViewer.swift; sourceTree = ""; }; @@ -1724,6 +1726,7 @@ 4030E5952A9DF488003E8CBA /* Views */ = { isa = PBXGroup; children = ( + 40901A602AE6A44F00B6831D /* LongPressToFocusViewModifier */, 40D7E7942AB1C9470017095E /* ThermalStateViewModifier */, 409BFA412A9F7BAA003341EF /* ReadableContentGuide */, 40F445FC2A9E2B3A004BE3DA /* WaitingLocalUserView */, @@ -1821,6 +1824,14 @@ path = CallState; sourceTree = ""; }; + 40901A602AE6A44F00B6831D /* LongPressToFocusViewModifier */ = { + isa = PBXGroup; + children = ( + 40901A5E2AE693B100B6831D /* LongPressToFocusViewModifier.swift */, + ); + path = LongPressToFocusViewModifier; + sourceTree = ""; + }; 409386182AA09E3900FF5AF4 /* MemoryLogDestination */ = { isa = PBXGroup; children = ( @@ -3773,6 +3784,7 @@ 40F445F02A9E2A4B004BE3DA /* LoadingView.swift in Sources */, 842145112AB1B81A00D1A4E9 /* DemoLocalViewModifier.swift in Sources */, 8456E6C6287EB55F004E180E /* AppState.swift in Sources */, + 40901A612AE6A48C00B6831D /* LongPressToFocusViewModifier.swift in Sources */, 40D946412AA5ECEF00C8861B /* CodeScanner.swift in Sources */, 40F445AE2A9DFC34004BE3DA /* UserState.swift in Sources */, 40F445F52A9E2AA1004BE3DA /* ReactionOverlayView.swift in Sources */, diff --git a/docusaurus/docs/iOS/05-ui-cookbook/15-long-press-to-focus.mdx b/docusaurus/docs/iOS/05-ui-cookbook/15-long-press-to-focus.mdx new file mode 100644 index 000000000..9c605eb49 --- /dev/null +++ b/docusaurus/docs/iOS/05-ui-cookbook/15-long-press-to-focus.mdx @@ -0,0 +1,131 @@ +--- +title: Long Press to Focus +description: Documentation on implementing a long press gesture to focus on a particular user in a StreamVideo call. +--- + +# Long Press to Focus + +The StreamVideo SDK allows for interactive and intuitive user engagement during calls. Implementing a long press gesture to focus your local participant's video feed can significantly enhance the user experience. + +## Overview + +- **Focus Implementation**: Focus on the specified point on your video feed. +- **Gesture Recognition**: Detect a long press or a tap on a participant's video feed. +- **User Experience**: Intuitive interaction for a more immersive call experience. + +## Implementing Long Press to Focus + +### Focusing on a point in the Video Feed + +In order to focus the camera at the desired point, we need to forward the request to the WebRTC videoCapturer. Before we do that we need to create a call and then join it: +```swift +// Create the call with the callType and id +let call = streamVideo.call(callType: "default", callId: "123") + +// Create the call on server side +let creationResult = try await call.create() + +// Join the call +let joinResult = try await call.join() +``` + +Once we are in the call, we can leverage the StreamVideo SDK to focus on a specific point on our **local video stream**: + +```swift +// Retrieve the desired focus point(e.g using a tap or longPress gesture) +let focusPoint: CGPoint = CGPoint(x: 50, y: 50) + +// and pass it to our call +try call.focus(at: focusPoint) +``` + +:::note +It's worth mentioning here that: +1. The focus on a point depends on the device's capabilities as the camera needs to support it. +2. We can only set the focus point for our local video stream and not for any of the other participants. +::: + +### Detecting the Long Press Gesture + +You can find an implementation that we are using in our Demo app to focus on long press. To achieve that we are using the following ViewModifier: +```swift +struct LongPressToFocusViewModifier: ViewModifier { + + var availableFrame: CGRect + + var handler: (CGPoint) -> Void + + func body(content: Content) -> some View { + content + .gesture( + LongPressGesture(minimumDuration: 0.5) + .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local)) + .onEnded { value in + switch value { + case .second(true, let drag): + if let location = drag?.location { + handler(convertToPointOfInterest(location)) + } + default: + break + } + } + ) + } + + func convertToPointOfInterest(_ point: CGPoint) -> CGPoint { + CGPoint( + x: point.y / availableFrame.height, + y: 1.0 - point.x / availableFrame.width + ) + } +} +``` + +We can then define a View extension to allow us easily use the ViewModifier: +```swift +extension View { + @ViewBuilder + func longPressToFocus( + availableFrame: CGRect, + handler: @escaping (CGPoint) -> Void + ) -> some View { + modifier( + LongPressToFocusViewModifier( + availableFrame: availableFrame, + handler: handler + ) + ) + } +} +``` + +### Modifying the UI SDK + +In order to use our ViewModifier and we can leverage the ViewFactory that the SDK ships with. By subclassing it we can override the method that provides the `VideoCallParticipantModifier` like below: + +```swift +override func makeVideoCallParticipantModifier( + participant: CallParticipant, + call: Call?, + availableFrame: CGRect, + ratio: CGFloat, + showAllInfo: Bool +) -> some ViewModifier { + super.makeVideoCallParticipantModifier( + participant: participant, + call: call, + availableFrame: availableFrame, + ratio: ratio, + showAllInfo: showAllInfo + ) + .longPressToFocus(availableFrame: availableFrame) { + guard call?.state.sessionId == participant.sessionId else { return } // We are using this to only allow long pressing on our local video feed + try? call?.focus(at: $0) + } +} +``` + +## Conclusion + +Implementing a long press to focus feature enhances user interaction, allowing participants to easily highlight and engage in a StreamVideo call. With customization options available developers have the flexibility to create a tailored and intuitive user experience. \ No newline at end of file