Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Calls/UserInterface/RemoteVideoView.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import MetalKit
import SignalRingRTC
import SignalServiceKit
import SignalUI
import WebRTC

class RemoteVideoView: UIView {
    private lazy var rtcMetalView = RTCMTLVideoView(frame: bounds)

    override init(frame: CGRect) {
        super.init(frame: frame)

        addSubview(rtcMetalView)
        rtcMetalView.autoPinEdgesToSuperviewEdges()
        // We want the rendered video to go edge-to-edge.
        rtcMetalView.layoutMargins = .zero
        // HACK: Although RTCMTLVideo view is positioned to the top edge of the screen
        // It's inner (private) MTKView is below the status bar.
        for subview in rtcMetalView.subviews {
            if subview is MTKView {
                subview.autoPinEdgesToSuperviewEdges()
            } else {
                owsFailDebug("New subviews added to MTLVideoView. Reconsider this hack.")
            }
        }

        applyDefaultRendererConfiguration()

        if Platform.isSimulator {
            backgroundColor = .blue.withAlphaComponent(0.4)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    var isScreenShare = false {
        didSet {
            if oldValue != isScreenShare {
                applyDefaultRendererConfigurationOnNextFrame = true
            }
        }
    }

    var isGroupCall = false {
        didSet {
            if oldValue != isGroupCall {
                applyDefaultRendererConfigurationOnNextFrame = true
            }
        }
    }

    var isFullScreen = false {
        didSet {
            if oldValue != isFullScreen {
                applyDefaultRendererConfigurationOnNextFrame = true
            }
        }
    }

    private var applyDefaultRendererConfigurationOnNextFrame = false

    private func applyDefaultRendererConfiguration() {
        if UIDevice.current.isIPad {
            rtcMetalView.videoContentMode = .scaleAspectFit
            rtcMetalView.rotationOverride = nil
        } else {
            rtcMetalView.videoContentMode = .scaleAspectFill
            rtcMetalView.rotationOverride = nil
        }
    }
}

extension RemoteVideoView: RTCVideoRenderer {

    func setSize(_ size: CGSize) {
        rtcMetalView.setSize(size)
    }

    func renderFrame(_ frame: RTCVideoFrame?) {
        rtcMetalView.renderFrame(frame)

        DispatchMainThreadSafe { [self] in
            if applyDefaultRendererConfigurationOnNextFrame {
                applyDefaultRendererConfigurationOnNextFrame = false
                applyDefaultRendererConfiguration()
            }

            guard let frame else { return }

            // In certain cases, rotate the video so it's always right side up in landscape.
            // We only allow portrait orientation in the calling views on iPhone so we don't
            // get this for free.
            let shouldOverrideRotation: Bool
            if UIDevice.current.isIPad || !isFullScreen {
                // iPad allows all orientations so we can skip this.
                // Non-full-screen views are part of a portrait-locked UI (e.g. the group call grid)
                // and rotating the video without rotating the UI would look weird.
                shouldOverrideRotation = false
            } else if isGroupCall {
                // For speaker view in a group call, keep screenshares right-side up.
                shouldOverrideRotation = isScreenShare
            } else {
                // For a 1:1 call, always keep video right-side up.
                shouldOverrideRotation = true
            }

            let isLandscape: Bool
            if shouldOverrideRotation {
                switch UIDevice.current.orientation {
                case .portrait, .portraitUpsideDown:
                    // We don't have to do anything, the renderer will automatically
                    // make sure it's right-side-up.
                    isLandscape = false
                    rtcMetalView.rotationOverride = nil

                case .landscapeLeft:
                    isLandscape = true
                    switch frame.rotation {
                    // Portrait upside-down
                    case ._270:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._0.rawValue)

                    // Portrait
                    case ._90:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._180.rawValue)

                    // Landscape right
                    case ._180:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._270.rawValue)

                    // Landscape left
                    case ._0:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._90.rawValue)

                    @unknown default:
                        owsFailBeta("unknown frame.rotation: \(frame.rotation)")
                    }

                case .landscapeRight:
                    isLandscape = true
                    switch frame.rotation {
                    // Portrait upside-down
                    case ._270:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._180.rawValue)

                    // Portrait
                    case ._90:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._0.rawValue)

                    // Landscape right
                    case ._180:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._90.rawValue)

                    // Landscape left
                    case ._0:
                        rtcMetalView.rotationOverride = NSNumber(value: RTCVideoRotation._270.rawValue)

                    @unknown default:
                        owsFailBeta("unknown frame.rotation: \(frame.rotation)")
                    }

                default:
                    // Do nothing if we're face down, up, etc.
                    // Assume we're already set up for the correct orientation.
                    isLandscape = false
                }
            } else {
                rtcMetalView.rotationOverride = nil
                isLandscape = bounds.width > bounds.height
            }

            let remoteIsLandscape = frame.rotation == RTCVideoRotation._180 || frame.rotation == RTCVideoRotation._0
            let isSquarish = max(bounds.width, bounds.height) / min(bounds.width, bounds.height) <= 1.2

            // If we're both in the same orientation, let the video fill the screen.
            // Otherwise, fit the video to the screen size respecting the aspect ratio.
            if isLandscape == remoteIsLandscape || isSquarish, !isScreenShare {
                rtcMetalView.videoContentMode = .scaleAspectFill
            } else {
                rtcMetalView.videoContentMode = .scaleAspectFit
            }
        }
    }
}