Fake Fullscreen Implementation Guide

When integrating StreamLayer SDK, the host app must manage its own "fake" fullscreen mode for the video player. This is required because AVPlayerViewController's native fullscreen removes the player from the view hierarchy, which breaks the StreamLayer overlay.

This guide covers how to:

  1. Disable the native AVPlayerViewController fullscreen button
  2. Add a custom fullscreen button
  3. Present video in a fullscreen layout managed by the host app

1. Disable Native Fullscreen

When creating your AVPlayerViewController, disable built-in fullscreen behavior:

let playerController = AVPlayerViewController()
playerController.entersFullScreenWhenPlaybackBegins = false
playerController.showsPlaybackControls = false // use custom controls
playerController.videoGravity = .resizeAspect

Setting showsPlaybackControls = false hides all default AVKit controls, including the fullscreen button. You will provide your own playback controls and fullscreen button.

Why not use native fullscreen? AVPlayerViewController's native fullscreen presents the player in its own fullscreen window, removing it from the host app's view hierarchy. This breaks the StreamLayer overlay which must remain layered on top of (or adjacent to) the player view at all times.


2. Add a Custom Fullscreen Button

Add a fullscreen toggle button to your custom player controls overlay:

private lazy var fullscreenButton: UIButton = {
    let button = UIButton(type: .custom)
    let config = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium)
    button.setImage(
        UIImage(systemName: "arrow.up.left.and.arrow.down.right", withConfiguration: config),
        for: .normal
    )
    button.tintColor = .white
    button.addTarget(self, action: #selector(toggleFullscreen), for: .touchUpInside)
    return button
}()

Place it in your controls overlay view (e.g. bottom-right corner of the player):

playerControlsView.addSubview(fullscreenButton)
fullscreenButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    fullscreenButton.trailingAnchor.constraint(equalTo: playerControlsView.trailingAnchor, constant: -16),
    fullscreenButton.bottomAnchor.constraint(equalTo: playerControlsView.bottomAnchor, constant: -16),
    fullscreenButton.widthAnchor.constraint(equalToConstant: 44),
    fullscreenButton.heightAnchor.constraint(equalToConstant: 44)
])

3. Implement Fake Fullscreen Layout

The idea is simple: in portrait, the player takes a portion of the screen (e.g. 16:9 at the top); in fullscreen/landscape, the player's constraints expand to fill the entire view. The StreamLayer overlay container adjusts accordingly.

3a. Track Fullscreen State

private var isFullscreen = false

3b. Toggle Fullscreen

@objc private func toggleFullscreen() {
    isFullscreen.toggle()
    updateFullscreenIcon()

    if isFullscreen {
        enterFullscreen()
    } else {
        exitFullscreen()
    }
}

private func updateFullscreenIcon() {
    let config = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium)
    let imageName = isFullscreen
        ? "arrow.down.right.and.arrow.up.left"  // collapse icon
        : "arrow.up.left.and.arrow.down.right"  // expand icon
    fullscreenButton.setImage(
        UIImage(systemName: imageName, withConfiguration: config),
        for: .normal
    )
}

3c. Enter Fullscreen — Expand Player to Fill Screen

Pre-build two sets of constraints and swap between them. Store them as properties:

private var portraitConstraints: [NSLayoutConstraint] = []
private var fullscreenConstraints: [NSLayoutConstraint] = []

private func buildConstraintSets() {
    portraitConstraints = [
        playerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
        playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        playerView.heightAnchor.constraint(equalTo: view.widthAnchor, multiplier: 9.0 / 16.0),

        overlayWrapperView.topAnchor.constraint(equalTo: playerView.bottomAnchor),
        overlayWrapperView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        overlayWrapperView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        overlayWrapperView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ]

    fullscreenConstraints = [
        playerView.topAnchor.constraint(equalTo: view.topAnchor),
        playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

        overlayWrapperView.topAnchor.constraint(equalTo: playerView.topAnchor),
        overlayWrapperView.leadingAnchor.constraint(equalTo: playerView.leadingAnchor),
        overlayWrapperView.trailingAnchor.constraint(equalTo: playerView.trailingAnchor),
        overlayWrapperView.bottomAnchor.constraint(equalTo: playerView.bottomAnchor)
    ]
}

Then toggle between them:

private func enterFullscreen() {
    // Force landscape orientation
    if #available(iOS 16.0, *) {
        guard let windowScene = view.window?.windowScene else { return }
        windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape))
        setNeedsUpdateOfSupportedInterfaceOrientations()
    } else {
        UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
    }

    // Swap constraint sets
    NSLayoutConstraint.deactivate(portraitConstraints)
    NSLayoutConstraint.activate(fullscreenConstraints)

    // Hide non-player content
    UIView.animate(withDuration: 0.25) {
        self.contentListView.alpha = 0
        self.view.layoutIfNeeded()
    }
}

3d. Exit Fullscreen — Restore Portrait Layout

private func exitFullscreen() {
    // Return to portrait
    if #available(iOS 16.0, *) {
        guard let windowScene = view.window?.windowScene else { return }
        windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
        setNeedsUpdateOfSupportedInterfaceOrientations()
    } else {
        UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
    }

    // Swap constraint sets
    NSLayoutConstraint.deactivate(fullscreenConstraints)
    NSLayoutConstraint.activate(portraitConstraints)

    // Show content list again
    UIView.animate(withDuration: 0.25) {
        self.contentListView.alpha = 1
        self.view.layoutIfNeeded()
    }
}

3e. Handle Device Rotation

Respond to device orientation changes so the layout stays in sync:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)

    let isLandscape = size.width > size.height

    coordinator.animate(alongsideTransition: { _ in
        if isLandscape {
            self.isFullscreen = true
            self.enterFullscreen()
        } else {
            self.isFullscreen = false
            self.exitFullscreen()
        }
        self.updateFullscreenIcon()
    })
}

3f. Support Rotation in the View Controller

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return .allButUpsideDown
}

override var shouldAutorotate: Bool {
    return true
}

4. Complete Minimal Example

Below is a condensed view controller showing all the pieces together:

import AVKit
import UIKit
import StreamLayerSDK

class PlayerViewController: UIViewController {

    // MARK: - State

    private var isFullscreen = false

    // MARK: - Player

    private let player = AVPlayer()

    private lazy var playerController: AVPlayerViewController = {
        let vc = AVPlayerViewController()
        vc.player = player
        vc.entersFullScreenWhenPlaybackBegins = false
        vc.showsPlaybackControls = false
        vc.videoGravity = .resizeAspect
        return vc
    }()

    // MARK: - Views

    /// Container for the AVPlayerViewController's view
    private let playerContainerView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    /// StreamLayer overlay is rendered inside this view
    private let overlayWrapperView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    /// Content below the player (list, chat, etc.)
    private let contentView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        return v
    }()

    /// Custom fullscreen button
    private lazy var fullscreenButton: UIButton = {
        let button = UIButton(type: .custom)
        button.translatesAutoresizingMaskIntoConstraints = false
        let config = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium)
        button.setImage(
            UIImage(systemName: "arrow.up.left.and.arrow.down.right", withConfiguration: config),
            for: .normal
        )
        button.tintColor = .white
        button.addTarget(self, action: #selector(toggleFullscreen), for: .touchUpInside)
        return button
    }()

    // MARK: - Constraint Storage

    /// Constraints active in portrait mode
    private var portraitConstraints: [NSLayoutConstraint] = []

    /// Constraints active in fullscreen mode
    private var fullscreenConstraints: [NSLayoutConstraint] = []

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black

        setupPlayerController()
        setupViews()
        buildConstraintSets()
        applyPortraitLayout()
    }

    // MARK: - Setup

    private func setupPlayerController() {
        addChild(playerController)
        playerContainerView.addSubview(playerController.view)
        playerController.view.frame = playerContainerView.bounds
        playerController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        playerController.didMove(toParent: self)
    }

    private func setupViews() {
        view.addSubview(playerContainerView)
        view.addSubview(overlayWrapperView)
        view.addSubview(contentView)
        view.addSubview(fullscreenButton)

        // Fullscreen button — always pinned to bottom-right of player container
        NSLayoutConstraint.activate([
            fullscreenButton.trailingAnchor.constraint(equalTo: playerContainerView.trailingAnchor, constant: -16),
            fullscreenButton.bottomAnchor.constraint(equalTo: playerContainerView.bottomAnchor, constant: -16),
            fullscreenButton.widthAnchor.constraint(equalToConstant: 44),
            fullscreenButton.heightAnchor.constraint(equalToConstant: 44)
        ])
    }

    // MARK: - Constraint Sets

    private func buildConstraintSets() {
        // --- Portrait: player at top (16:9), overlay & content below ---
        portraitConstraints = [
            playerContainerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            playerContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            playerContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            playerContainerView.heightAnchor.constraint(equalTo: view.widthAnchor, multiplier: 9.0 / 16.0),

            overlayWrapperView.topAnchor.constraint(equalTo: playerContainerView.bottomAnchor),
            overlayWrapperView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            overlayWrapperView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            overlayWrapperView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            contentView.topAnchor.constraint(equalTo: playerContainerView.bottomAnchor),
            contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ]

        // --- Fullscreen: player fills screen, overlay matches player ---
        fullscreenConstraints = [
            playerContainerView.topAnchor.constraint(equalTo: view.topAnchor),
            playerContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            playerContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            playerContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            overlayWrapperView.topAnchor.constraint(equalTo: playerContainerView.topAnchor),
            overlayWrapperView.leadingAnchor.constraint(equalTo: playerContainerView.leadingAnchor),
            overlayWrapperView.trailingAnchor.constraint(equalTo: playerContainerView.trailingAnchor),
            overlayWrapperView.bottomAnchor.constraint(equalTo: playerContainerView.bottomAnchor)
        ]
    }

    // MARK: - Layout

    private func applyPortraitLayout() {
        NSLayoutConstraint.deactivate(fullscreenConstraints)
        NSLayoutConstraint.activate(portraitConstraints)
        contentView.alpha = 1
    }

    private func applyFullscreenLayout() {
        NSLayoutConstraint.deactivate(portraitConstraints)
        NSLayoutConstraint.activate(fullscreenConstraints)
        contentView.alpha = 0
    }

    // MARK: - Fullscreen Toggle

    @objc private func toggleFullscreen() {
        isFullscreen.toggle()

        let iconName = isFullscreen
            ? "arrow.down.right.and.arrow.up.left"
            : "arrow.up.left.and.arrow.down.right"
        let config = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium)
        fullscreenButton.setImage(UIImage(systemName: iconName, withConfiguration: config), for: .normal)

        if isFullscreen {
            applyFullscreenLayout()
            requestLandscape()
        } else {
            applyPortraitLayout()
            requestPortrait()
        }

        UIView.animate(withDuration: 0.25) {
            self.view.layoutIfNeeded()
        }
    }

    // MARK: - Orientation

    private func requestLandscape() {
        if #available(iOS 16.0, *) {
            guard let scene = view.window?.windowScene else { return }
            scene.requestGeometryUpdate(.iOS(interfaceOrientations: .landscape))
            setNeedsUpdateOfSupportedInterfaceOrientations()
        } else {
            UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
        }
    }

    private func requestPortrait() {
        if #available(iOS 16.0, *) {
            guard let scene = view.window?.windowScene else { return }
            scene.requestGeometryUpdate(.iOS(interfaceOrientations: .portrait))
            setNeedsUpdateOfSupportedInterfaceOrientations()
        } else {
            UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
        }
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        let isLandscape = size.width > size.height
        coordinator.animate(alongsideTransition: { _ in
            self.isFullscreen = isLandscape
            if isLandscape {
                self.applyFullscreenLayout()
            } else {
                self.applyPortraitLayout()
            }
            let iconName = self.isFullscreen
                ? "arrow.down.right.and.arrow.up.left"
                : "arrow.up.left.and.arrow.down.right"
            let config = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium)
            self.fullscreenButton.setImage(UIImage(systemName: iconName, withConfiguration: config), for: .normal)
            self.view.layoutIfNeeded()
        })
    }

    override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .allButUpsideDown }
    override var shouldAutorotate: Bool { true }
}

Key Points

RequirementHow
Disable native fullscreenentersFullScreenWhenPlaybackBegins = false + showsPlaybackControls = false
Custom fullscreen buttonYour own UIButton placed over the player, calls toggleFullscreen()
Fake fullscreen layoutRemake constraints so the player fills the screen; hide other content
StreamLayer overlay syncRemake overlayWrapperView constraints to match player bounds in both modes
Orientation handlingForce orientation programmatically + respond to viewWillTransition(to:with:)

The player view (AVPlayerViewController.view) always stays in your view hierarchy. You never call enterFullScreen() or present it modally — you only change its constraints. This keeps the StreamLayer overlay functional at all times.