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:
- Disable the native AVPlayerViewController fullscreen button
- Add a custom fullscreen button
- 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 = .resizeAspectSetting 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 = false3b. 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
| Requirement | How |
|---|---|
| Disable native fullscreen | entersFullScreenWhenPlaybackBegins = false + showsPlaybackControls = false |
| Custom fullscreen button | Your own UIButton placed over the player, calls toggleFullscreen() |
| Fake fullscreen layout | Remake constraints so the player fills the screen; hide other content |
| StreamLayer overlay sync | Remake overlayWrapperView constraints to match player bounds in both modes |
| Orientation handling | Force 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.
Updated about 4 hours ago
