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

import SignalServiceKit
import SignalUI

protocol MediaControlPanelDelegate: GalleryRailViewDelegate {
    func mediaControlPanelDidRequestForwardMedia(_ panel: MediaControlPanelView)
    func mediaControlPanelDidRequestShareMedia(_ panel: MediaControlPanelView)
}

/// Bottom panel for full-screen media viewer.
/// Contains:
/// • media "caption" (text of the message that contains currently visible media).
/// • thumbnail strip if current media is a part of an "album" (media sent in one message).
/// • Share and Forward buttons when in portrait orientation.
/// • interactive video player playback bar (hidden for photos).
/// • video playback controls (play/pause, rewind, fast forward) (hidden for photos).
class MediaControlPanelView: UIView {

    private let mediaGallery: MediaGallery
    private let spoilerState: SpoilerRenderState
    private weak var delegate: MediaControlPanelDelegate?

    // Add all content here.
    private var contentView: UIView!

    init(
        mediaGallery: MediaGallery,
        delegate: MediaControlPanelDelegate,
        spoilerState: SpoilerRenderState,
    ) {
        self.mediaGallery = mediaGallery
        self.delegate = delegate
        self.spoilerState = spoilerState

        super.init(frame: .zero)

        tintColor = .Signal.label
        directionalLayoutMargins = .zero
        preservesSuperviewLayoutMargins = true
        isVerticallyCompactLayout = traitCollection.verticalSizeClass == .compact

        // iOS 26: Glass Container
        // Pre-iOS 26: Blur Background
        let visualEffect: UIVisualEffect = if #available(iOS 26, *) { UIGlassContainerEffect() } else { UIBlurEffect(style: .systemChromeMaterial) }
        let visualEffectView = UIVisualEffectView(effect: visualEffect)
        visualEffectView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(visualEffectView)
        NSLayoutConstraint.activate([
            visualEffectView.topAnchor.constraint(equalTo: topAnchor),
            visualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor),
            visualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor),
            visualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])

        contentView = visualEffectView.contentView

        setupSubviews()

        // New APIs for tracking trait changes.
        if #available(iOS 17, *) {
            registerForTraitChanges([UITraitVerticalSizeClass.self]) { (self: Self, previousTraitCollection) in
                self.isVerticallyCompactLayout = self.traitCollection.verticalSizeClass == .compact
            }
        }
    }

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

    // MARK: Subviews

    private struct ContentLayoutGuideEdgeConstraints {
        let top: NSLayoutConstraint
        let leading: NSLayoutConstraint
        let trailing: NSLayoutConstraint
        let bottom: NSLayoutConstraint
    }

    // This will be used to define area for entire contents.
    // Other layout guides will be constrained relative to this one.
    private let contentLayoutGuide = UILayoutGuide()
    // Adjustable and therefore non-nil on iOS 26.
    private var contentLayoutGuideEdgeConstraints: ContentLayoutGuideEdgeConstraints?
    // On iOS 26 bottom content margin is fixed on all devices in all orientations.
    private static let contentLayoutGuideBottomMargin: CGFloat = 28

    // Glass background for when caption view and video progress bar are joined in one glass panel.
    // Both controls also have their own glass backgrounds that can be disabled.
    // It is developer's responsibility to enable either shared glass background
    // or individual glass backgrounds.
    private var captionAndMediaControlsGlassBackgroundView: UIVisualEffectView?

    // Top most area.
    private let captionViewArea = UILayoutGuide()
    private lazy var captionView = MediaCaptionView(spoilerState: spoilerState)

    // Second from the top area.
    private let videoPlayerControlsArea = UILayoutGuide()
    private(set) var videoPlaybackControlView: VideoPlaybackControlView?
    private var videoPlaybackProgressView: PlayerProgressView?
    private var videoPlayerControlsConstraintsPortrait = [NSLayoutConstraint]()
    private var videoPlayerControlsConstraintsLandscape = [NSLayoutConstraint]()

    // Third from the top area.
    private let thumbnailStripArea = UILayoutGuide()
    private lazy var thumbnailStrip: GalleryRailView = {
        let view = GalleryRailView()
        view.delegate = delegate
        view.itemSize = 40
        view.layoutMargins.top = 0
        view.layoutMargins.bottom = 6
        view.isScrollEnabled = false
        return view
    }()

    // Bottom area.
    // Not visible in landscape (`compact` vertical size class).
    private let buttonArea = UILayoutGuide()
    private var buttonAreaLeadingEdgeConstraint: NSLayoutConstraint?
    private var buttonAreaTrailingEdgeConstraint: NSLayoutConstraint?

    // These allow to shrink areas to a zero height making them not visible.
    private lazy var captionViewAreaZeroHeightConstraint = captionViewArea.heightAnchor.constraint(equalToConstant: 0)
    private lazy var videoPlayerControlsAreaZeroHeightConstraint =
        videoPlayerControlsArea.heightAnchor.constraint(equalToConstant: 0)
    private lazy var thumbnailStripAreaZeroHeightConstraint = thumbnailStripArea.heightAnchor.constraint(equalToConstant: 0)
    private lazy var buttonAreaZeroHeightConstraint = buttonArea.heightAnchor.constraint(equalToConstant: 0)

    // These control vertical spacing between UI elements (their layout guides).
    private var captionViewAreaBottomToVideoPlayerControlsAreaTop: NSLayoutConstraint?
    private var videoPlayerControlsAreaBottomToThumbnailStripTop: NSLayoutConstraint?
    private var thumbnailStripAreaBottomToButtonsAreaTop: NSLayoutConstraint?

    // This controls how large the bottom button is. Insets are added to 24x24 button icons.
    private static let buttonContentInset: CGFloat = if #available(iOS 26, *) { 10 } else { 8 }
    private lazy var buttonForwardMedia: UIButton = {
        let configuration: UIButton.Configuration = if #available(iOS 26, *) { .glass() } else { .plain() }
        let button = UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in
            self?.didPressForward()
        })
        button.configuration?.image = Theme.iconImage(.buttonForward)
        button.configuration?.contentInsets = .init(margin: Self.buttonContentInset)
        button.accessibilityLabel = OWSLocalizedString("ACCESSIBILITY_FORWARD_BUTTON_LABEL", comment: "Label for forward button in media control panel")
        return button
    }()

    private lazy var buttonShareMedia: UIButton = {
        let configuration: UIButton.Configuration = if #available(iOS 26, *) { .glass() } else { .plain() }
        let button = UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in
            self?.didPressShare()
        })
        button.configuration?.image = Theme.iconImage(.buttonShare)
        button.configuration?.contentInsets = .init(margin: Self.buttonContentInset)
        return button
    }()

    // Convenience method to create a "regular" glass effect that is interactive.
    @available(iOS 26, *)
    private func interactiveGlassEffect() -> UIVisualEffect? {
        let glassEffect = UIGlassEffect(style: .regular)
        glassEffect.isInteractive = true
        return glassEffect
    }

    // MARK: Layout

    private(set) var isVerticallyCompactLayout: Bool = false {
        didSet {
            guard oldValue != isVerticallyCompactLayout else { return }

            updateCaptionAndVideoControls()
            updateThumbnailStripLayout()
            updateBottomButtonsLayout()

            setNeedsUpdateConstraints()
        }
    }

    // Returns true if interface is landscape and current media is not a video or there's a caption.
    var shouldBeHidden: Bool {
        guard isVerticallyCompactLayout else { return false }
        if let currentItem, currentItem.isVideo {
            return false
        }
        if captionView.isEmpty == false {
            return false
        }
        // On iOS 26 we do show thumbnail strip in landscape because bottom panel doesn't have a background.
        if #available(iOS 26, *) {
            return showThumbnailStrip == false
        }
        return true
    }

    private func setupSubviews() {
        //
        // I. Setup layout guides.
        //
        contentLayoutGuide.identifier = "ContentArea"
        captionViewArea.identifier = "CaptionViewArea"
        videoPlayerControlsArea.identifier = "VideoPlayerControlsArea"
        thumbnailStripArea.identifier = "ThumbnailStripArea"
        buttonArea.identifier = "ButtonArea"

        addLayoutGuide(contentLayoutGuide)
        addLayoutGuide(captionViewArea)
        addLayoutGuide(videoPlayerControlsArea)
        addLayoutGuide(thumbnailStripArea)
        addLayoutGuide(buttonArea)

        //
        // i. Content layout guide.
        //
        let topEdgeConstraint: NSLayoutConstraint
        let leadingEdgeConstraint: NSLayoutConstraint
        let trailingEdgeConstraint: NSLayoutConstraint
        let bottomEdgeConstraint: NSLayoutConstraint

        if #available(iOS 26, *) {
            // Top edge is always at the view's top edge.
            topEdgeConstraint = contentLayoutGuide.topAnchor.constraint(equalTo: topAnchor)

            // iOS 26 landscape (non-zero left and right safe area insets): aligned with outside edges of navigation bar
            // buttons (fixed 38 pts margin).
            // iOS 26 portrait, landscape on older phones: aligned to view's leading and trailing margins.
            leadingEdgeConstraint = contentLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor)
            trailingEdgeConstraint = contentLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor)

            // Fixed distance to bottom edge on all devices in all orientations.
            bottomEdgeConstraint = contentLayoutGuide.bottomAnchor.constraint(
                equalTo: bottomAnchor,
                constant: -Self.contentLayoutGuideBottomMargin,
            )
        } else {
            // Top edge is always at the view's top edge with a default padding.
            topEdgeConstraint = contentLayoutGuide.topAnchor.constraint(equalTo: topAnchor, constant: 8)

            // Pre-iOS 26: aligned to view's leading and trailing margins.
            leadingEdgeConstraint = contentLayoutGuide.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor)
            trailingEdgeConstraint = contentLayoutGuide.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor)
            // Bottom buttons have padding baked in and therefore can rest against view's bottom edge
            // if there's no bottom safe area inset (home button devices, landscape layout).
            bottomEdgeConstraint = contentLayoutGuide.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
        }

        NSLayoutConstraint.activate([
            topEdgeConstraint,
            leadingEdgeConstraint,
            trailingEdgeConstraint,
            bottomEdgeConstraint,
        ])

        // Keep reference to constraints and update them as necessary (see `updateConstraints`).
        if #available(iOS 26, *) {
            contentLayoutGuideEdgeConstraints = .init(
                top: topEdgeConstraint,
                leading: leadingEdgeConstraint,
                trailing: trailingEdgeConstraint,
                bottom: bottomEdgeConstraint,
            )
            updateContentLayoutGuideEdgeConstraints()
        }

        //
        // ii. Setup Layout guides as a vertical stack within `contentLayoutGuide`.
        //
        let buttonAreaLeadingEdgeConstraint = buttonArea.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor)
        let buttonAreaTrailingEdgeConstraint = buttonArea.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor)
        if #available(iOS 26, *) {
            // Remember those with the purpose of updating as necessary in `updateConstraints`.
            // We do this because bottom buttons by design might need to be moved further away from screen edges.
            self.buttonAreaLeadingEdgeConstraint = buttonAreaLeadingEdgeConstraint
            self.buttonAreaTrailingEdgeConstraint = buttonAreaTrailingEdgeConstraint

            updateButtonAreaEdgeConstraints()
        } else {
            // Actual buttons are a little bit larger than their images.
            // Offset leading and trailing edges by the same amount
            // to make visible button shapes to be aligned with view's layout margins.
            buttonAreaLeadingEdgeConstraint.constant = -Self.buttonContentInset
            buttonAreaTrailingEdgeConstraint.constant = Self.buttonContentInset
        }

        // Vertical spacers between layout guides.
        // All are initialized with `0` constant value that matches initial layout.
        captionViewAreaBottomToVideoPlayerControlsAreaTop = videoPlayerControlsArea.topAnchor.constraint(
            equalTo: captionViewArea.bottomAnchor,
        )
        videoPlayerControlsAreaBottomToThumbnailStripTop = thumbnailStripArea.topAnchor.constraint(
            equalTo: videoPlayerControlsArea.bottomAnchor,
        )
        thumbnailStripAreaBottomToButtonsAreaTop = buttonArea.topAnchor.constraint(
            equalTo: thumbnailStripArea.bottomAnchor,
        )

        NSLayoutConstraint.activate([
            // a. Horizontal.
            captionViewArea.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
            captionViewArea.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor),

            videoPlayerControlsArea.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
            videoPlayerControlsArea.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor),

            // This control is horizontally scrollable and therefore goes edge-to-edge.
            thumbnailStripArea.leadingAnchor.constraint(equalTo: leadingAnchor),
            thumbnailStripArea.trailingAnchor.constraint(equalTo: trailingAnchor),

            buttonAreaLeadingEdgeConstraint,
            buttonAreaTrailingEdgeConstraint,

            // b. Vertical
            captionViewArea.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
            captionViewAreaBottomToVideoPlayerControlsAreaTop!,
            videoPlayerControlsAreaBottomToThumbnailStripTop!,
            thumbnailStripAreaBottomToButtonsAreaTop!,
            buttonArea.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),

            // Initialize all areas with zero height constraint active - matches initial state.
            captionViewAreaZeroHeightConstraint,
            videoPlayerControlsAreaZeroHeightConstraint,
            thumbnailStripAreaZeroHeightConstraint,
        ])

        // Setup views.
        captionView.translatesAutoresizingMaskIntoConstraints = false
        thumbnailStrip.translatesAutoresizingMaskIntoConstraints = false
        buttonForwardMedia.translatesAutoresizingMaskIntoConstraints = false
        buttonShareMedia.translatesAutoresizingMaskIntoConstraints = false

        // These are hidden initially.
        // Video player controls are created on demand.
        captionView.isHidden = true
        thumbnailStrip.isHidden = true

        if #available(iOS 26, *) {
            let glassEffectView = UIVisualEffectView(effect: nil)
            glassEffectView.translatesAutoresizingMaskIntoConstraints = false
            glassEffectView.clipsToBounds = true
            glassEffectView.cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: 26))
            contentView.addSubview(glassEffectView)
            NSLayoutConstraint.activate([
                glassEffectView.topAnchor.constraint(equalTo: captionViewArea.topAnchor),
                glassEffectView.leadingAnchor.constraint(equalTo: captionViewArea.leadingAnchor),
                glassEffectView.trailingAnchor.constraint(equalTo: captionViewArea.trailingAnchor),
                glassEffectView.bottomAnchor.constraint(equalTo: videoPlayerControlsArea.bottomAnchor),
            ])

            glassEffectView.contentView.addSubview(captionView)

            captionAndMediaControlsGlassBackgroundView = glassEffectView
        } else {
            contentView.addSubview(captionView)
        }

        contentView.addSubview(thumbnailStrip)
        contentView.addSubview(buttonForwardMedia)
        contentView.addSubview(buttonShareMedia)

        // Constraints with non-required priority allow us to shrink height of the corresponding layout guide
        // to zero while keeping height of the UI elements intact. Combined with setting element's alpha to `0` or `1`
        // we get nice animations when switching between media items.
        NSLayoutConstraint.activate([
            captionView.leadingAnchor.constraint(equalTo: captionViewArea.leadingAnchor),
            captionView.topAnchor.constraint(equalTo: captionViewArea.topAnchor),
            captionView.trailingAnchor.constraint(equalTo: captionViewArea.trailingAnchor),
            {
                let constraint = captionView.bottomAnchor.constraint(equalTo: captionViewArea.bottomAnchor)
                constraint.priority = .defaultHigh + 100
                return constraint
            }(),

            {
                let constraint = thumbnailStrip.topAnchor.constraint(equalTo: thumbnailStripArea.topAnchor)
                constraint.priority = .defaultHigh + 100
                return constraint
            }(),
            thumbnailStrip.leadingAnchor.constraint(equalTo: thumbnailStripArea.leadingAnchor),
            thumbnailStrip.trailingAnchor.constraint(equalTo: thumbnailStripArea.trailingAnchor),
            thumbnailStrip.bottomAnchor.constraint(equalTo: thumbnailStripArea.bottomAnchor),

            buttonShareMedia.topAnchor.constraint(equalTo: buttonArea.topAnchor),
            buttonShareMedia.widthAnchor.constraint(equalTo: buttonShareMedia.heightAnchor),
            buttonShareMedia.leadingAnchor.constraint(equalTo: buttonArea.leadingAnchor),
            {
                let constraint = buttonShareMedia.bottomAnchor.constraint(equalTo: buttonArea.bottomAnchor)
                constraint.priority = .defaultHigh + 100
                return constraint
            }(),

            buttonForwardMedia.topAnchor.constraint(equalTo: buttonArea.topAnchor),
            buttonForwardMedia.widthAnchor.constraint(equalTo: buttonForwardMedia.heightAnchor),
            buttonForwardMedia.trailingAnchor.constraint(equalTo: buttonArea.trailingAnchor),
            {
                let constraint = buttonForwardMedia.bottomAnchor.constraint(equalTo: buttonArea.bottomAnchor)
                constraint.priority = .defaultHigh + 100
                return constraint
            }(),
        ])

        // TODO: Add "Read More"
        captionView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapCaption)))

        updateBottomButtonsLayout()
    }

    override func updateConstraints() {
        super.updateConstraints()

        // Video playback controls.
        let showVideoPlaybackControls = (videoPlayer != nil)
        if showVideoPlaybackControls {
            if isVerticallyCompactLayout {
                NSLayoutConstraint.deactivate(videoPlayerControlsConstraintsPortrait)
                NSLayoutConstraint.activate(videoPlayerControlsConstraintsLandscape)
            } else {
                NSLayoutConstraint.deactivate(videoPlayerControlsConstraintsLandscape)
                NSLayoutConstraint.activate(videoPlayerControlsConstraintsPortrait)
            }
        }

        if #available(iOS 26, *) {
            updateContentLayoutGuideEdgeConstraints()
            updateButtonAreaEdgeConstraints()
        }
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        // Trait collection change tracking on iOS 17+ is set up in `init()` using newer APIs.
        if #unavailable(iOS 17), previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass {
            isVerticallyCompactLayout = traitCollection.verticalSizeClass == .compact
        }
    }

    override func layoutMarginsDidChange() {
        super.layoutMarginsDidChange()

        // On iOS 26 the layout is more flexible and depends on view's layout margins.
        if #available(iOS 26, *) {
            setNeedsUpdateConstraints()
        }
    }

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()

        // On iOS 26 the layout is more flexible and depends on view's safe area insets.
        if #available(iOS 26, *) {
            setNeedsUpdateConstraints()
        }
    }

    @available(iOS 26, *)
    private func updateContentLayoutGuideEdgeConstraints() {
        guard let contentLayoutGuideEdgeConstraints else { return }

        let isLandscapeLayout = traitCollection.verticalSizeClass == .compact
        let horizontalInset: CGFloat = 38

        // For leading and trailing edges we have custom margins in landscape on modern iPhones (non-home button).
        // Those margins are designed to align content with navigation bar buttons.
        if isLandscapeLayout, safeAreaInsets.leading > 0 {
            contentLayoutGuideEdgeConstraints.leading.constant = horizontalInset
        } else {
            contentLayoutGuideEdgeConstraints.leading.constant = directionalLayoutMargins.leading
        }
        if isLandscapeLayout, safeAreaInsets.trailing > 0 {
            contentLayoutGuideEdgeConstraints.trailing.constant = -horizontalInset
        } else {
            contentLayoutGuideEdgeConstraints.trailing.constant = -directionalLayoutMargins.trailing
        }
    }

    @available(iOS 26, *)
    private func updateButtonAreaEdgeConstraints() {
        guard let buttonAreaLeadingEdgeConstraint, let buttonAreaTrailingEdgeConstraint else { return }

        var leadingInset: CGFloat = 0
        var trailingInset: CGFloat = 0
        // In portrait, shrink the button area, making its leading and trailing insets
        // equal to the bottom content layout guide inset.
        // The purpose is the place Share and Forward buttons squarely in their respective corners of the screen.
        let isLandscapeLayout = traitCollection.verticalSizeClass == .compact
        if !isLandscapeLayout {
            // To calculate inset relative to `contentLayoutGuide`'s leading and trailing anchors
            // we rely on the fact that they are constrained without offset to view's leading and trailing anchors.
            // More correct approach would have been to use `contentLayoutGuide.layoutFrame` but that one
            // might not yet be at it's final dimensions at this point.
            let screenEdgeInset = Self.contentLayoutGuideBottomMargin
            leadingInset = screenEdgeInset - directionalLayoutMargins.leading
            trailingInset = screenEdgeInset - directionalLayoutMargins.trailing
        }
        buttonAreaLeadingEdgeConstraint.constant = leadingInset
        buttonAreaTrailingEdgeConstraint.constant = -trailingInset
    }

    // MARK: Expandable Caption

    @objc
    private func didTapCaption(_ gestureRecognizer: UITapGestureRecognizer) {
        // Let the view handle first; if it does, exit.
        if captionView.handleTap(gestureRecognizer) {
            return
        }

        guard captionView.canBeExpanded else { return }

        let animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 0.645, springResponse: 0.25)
        animator.addAnimations {
            self.captionView.isExpanded = !self.captionView.isExpanded
            self.layoutIfNeeded()
        }
        animator.startAnimation()
    }

    // MARK: Media Rail

    private static var galleryCellConfiguration: GalleryRailCellConfiguration = {
        // On iOS 26 selected thumbnail doesn't have a border, but instead
        // it has some extra space around it. Similar to what Photos app does.
        let borderColor: UIColor
        let borderWidth: CGFloat
        let extraPadding: CGFloat
        if #available(iOS 26, *) {
            borderColor = .clear
            borderWidth = 0
            extraPadding = 8
        } else {
            borderColor = .white
            borderWidth = 2
            extraPadding = 0
        }
        return GalleryRailCellConfiguration(
            cornerRadius: 6,
            itemBorderWidth: 0,
            itemBorderColor: nil,
            focusedItemBorderWidth: borderWidth,
            focusedItemBorderColor: borderColor,
            focusedItemOverlayColor: nil,
            focusedItemExtraPadding: extraPadding,
        )
    }()

    // Thumbnail strip is shown for albums (>1 media in one message):
    // iOS 26: all interface orientations.
    // Pre-iOS 26: portrait orientations only (`regular` vertical size class).
    private var showThumbnailStrip: Bool {
        if #unavailable(iOS 26), isVerticallyCompactLayout { return false }
        guard let currentMediaAlbum else { return false }
        return currentMediaAlbum.items.count > 1
    }

    // MARK: Video Playback Controls

    private func getOrCreateVideoPlaybackControlView() -> VideoPlaybackControlView {
        if let videoPlaybackControlView {
            return videoPlaybackControlView
        }
        let videoPlaybackControlView = VideoPlaybackControlView()
        videoPlaybackControlView.translatesAutoresizingMaskIntoConstraints = false
        videoPlaybackControlView.delegate = self
        videoPlaybackControlView.isHidden = true
        contentView.addSubview(videoPlaybackControlView)

        // Portrait constraints.
        let portraitConstraints = [
            videoPlaybackControlView.topAnchor.constraint(equalTo: buttonArea.topAnchor),
            videoPlaybackControlView.bottomAnchor.constraint(equalTo: buttonArea.bottomAnchor),
            videoPlaybackControlView.centerXAnchor.constraint(equalTo: buttonArea.centerXAnchor),
        ]
        videoPlayerControlsConstraintsPortrait += portraitConstraints

        // Landscape constraints.
        let landscapeConstraints = [
            videoPlaybackControlView.leadingAnchor.constraint(equalTo: videoPlayerControlsArea.leadingAnchor),
            videoPlaybackControlView.topAnchor.constraint(equalTo: videoPlayerControlsArea.topAnchor),
            videoPlaybackControlView.bottomAnchor.constraint(equalTo: videoPlayerControlsArea.bottomAnchor),
        ]
        videoPlayerControlsConstraintsLandscape += landscapeConstraints

        if isVerticallyCompactLayout {
            NSLayoutConstraint.activate(landscapeConstraints)
        } else {
            NSLayoutConstraint.activate(portraitConstraints)
        }

        self.videoPlaybackControlView = videoPlaybackControlView

        return videoPlaybackControlView
    }

    private func getOrCreateVideoPlaybackProgressView() -> PlayerProgressView {
        if let videoPlaybackProgressView {
            return videoPlaybackProgressView
        }

        let videoPlaybackProgressView = PlayerProgressView()
        videoPlaybackProgressView.isHidden = true
        videoPlaybackProgressView.translatesAutoresizingMaskIntoConstraints = false
        if let captionAndMediaControlsGlassBackgroundView {
            captionAndMediaControlsGlassBackgroundView.contentView.addSubview(videoPlaybackProgressView)
        } else {
            contentView.addSubview(videoPlaybackProgressView)
        }

        // Portrait constraints.
        let portraitConstraints = [
            videoPlaybackProgressView.topAnchor.constraint(equalTo: videoPlayerControlsArea.topAnchor),
            videoPlaybackProgressView.leadingAnchor.constraint(equalTo: videoPlayerControlsArea.leadingAnchor),
            videoPlaybackProgressView.trailingAnchor.constraint(equalTo: videoPlayerControlsArea.trailingAnchor),
            {
                let constraint = videoPlaybackProgressView.bottomAnchor.constraint(
                    equalTo: videoPlayerControlsArea.bottomAnchor,
                )
                constraint.priority = .defaultHigh + 100
                return constraint
            }(),
        ]
        videoPlayerControlsConstraintsPortrait += portraitConstraints

        // Landscape constraints.
        let videoPlaybackControlView = getOrCreateVideoPlaybackControlView()
        let landscapeConstraints = [
            videoPlaybackProgressView.topAnchor.constraint(equalTo: videoPlayerControlsArea.topAnchor),
            videoPlaybackProgressView.leadingAnchor.constraint(equalTo: videoPlaybackControlView.trailingAnchor, constant: 16),
            videoPlaybackProgressView.trailingAnchor.constraint(equalTo: videoPlayerControlsArea.trailingAnchor),
            videoPlaybackProgressView.bottomAnchor.constraint(equalTo: videoPlayerControlsArea.bottomAnchor),
        ]
        videoPlayerControlsConstraintsLandscape += landscapeConstraints

        if isVerticallyCompactLayout {
            NSLayoutConstraint.activate(landscapeConstraints)
        } else {
            NSLayoutConstraint.activate(portraitConstraints)
        }

        self.videoPlaybackProgressView = videoPlaybackProgressView

        return videoPlaybackProgressView
    }

    // MARK: Media Item

    private var currentItem: MediaGalleryItem?

    private var currentMediaAlbum: MediaGalleryAlbum?

    private var videoPlayer: VideoPlayer?

    func configureWithMediaItem(
        _ item: MediaGalleryItem,
        videoPlayer: VideoPlayer?,
        transitionDirection: UIPageViewController.NavigationDirection,
        animated: Bool,
    ) {
        guard currentItem !== item else { return }

        currentItem = item
        if currentMediaAlbum?.items.contains(item) != true {
            currentMediaAlbum = mediaGallery.album(for: item)
        }

        var animator: UIViewPropertyAnimator?
        if animated {
            animator = UIViewPropertyAnimator(
                duration: 0.35,
                springDamping: 1,
                springResponse: 0.35,
            )
        }

        // Create video playback controls if necessary.
        if let videoPlayer, item.isVideo {
            self.videoPlayer = videoPlayer

            let playerControlsView = getOrCreateVideoPlaybackControlView()
            playerControlsView.updateWithMediaItem(item)
            playerControlsView.updateStatusWithPlayer(videoPlayer)

            let playerProgressView = getOrCreateVideoPlaybackProgressView()
            playerProgressView.videoPlayer = videoPlayer
        } else {
            self.videoPlayer = nil
        }

        // Animate caption view and video player progress bar together.
        updateCaptionAndVideoControls(using: animator)

        // Don't update thumbnail strip if we're going to hide it - for better visual experience.
        if showThumbnailStrip {
            thumbnailStrip.configureCellViews(
                itemProvider: currentMediaAlbum!,
                focusedItem: item,
                cellViewBuilder: { _ in
                    return GalleryRailCellView(configuration: Self.galleryCellConfiguration)
                },
                animated: animated && thumbnailStrip.isHidden == false,
            )
        }
        updateThumbnailStripLayout(using: animator, transitionDirection: transitionDirection)

        if let animator {
            animator.startAnimation()
        }
    }

    // MARK: Animations

    private func updateCaptionAndVideoControls(using animator: UIViewPropertyAnimator? = nil) {
        let captionForDisplay = currentItem?.captionForDisplay

        // Prepare current visibility state of UI elements.
        let showCaptionView = captionForDisplay?.nilIfEmpty != nil
        let showVideoPlayerControls = videoPlayer != nil
        let showThumbnailStrip = showThumbnailStrip
        let useSharedGlassBackground: Bool = {
            if isVerticallyCompactLayout {
                false
            } else if #available(iOS 26, *) {
                showCaptionView || showVideoPlayerControls
            } else {
                false
            }
        }()

        // Spacing between caption or video progress view and thumbnail strip.
        let verticalSpacing: CGFloat = {
            if #available(iOS 26, *) {
                isVerticallyCompactLayout ? 20 : 24
            } else {
                8
            }
        }()
        let captionViewAreaBottomSpacing: CGFloat = {
            if showCaptionView == false {
                0
            } else if useSharedGlassBackground {
                showVideoPlayerControls ? -8 : 0
            } else if showVideoPlayerControls || showThumbnailStrip || isVerticallyCompactLayout == false {
                verticalSpacing
            } else {
                0
            }
        }()
        let videoPlayerControlsAreaBottomSpacing: CGFloat = {
            if useSharedGlassBackground {
                verticalSpacing
            } else if showVideoPlayerControls == false {
                0
            } else if showThumbnailStrip || isVerticallyCompactLayout == false {
                verticalSpacing
            } else {
                0
            }
        }()

        // No animations.
        guard let animator else {
            // Update caption box.
            captionView.content = captionForDisplay
            if showCaptionView {
                // Just in case, make sure the view is properly configured after previous animations.
                captionView.animateIn()
            }
            captionView.isHidden = showCaptionView == false
            captionViewAreaZeroHeightConstraint.isActive = showCaptionView == false
            captionViewAreaBottomToVideoPlayerControlsAreaTop?.constant = captionViewAreaBottomSpacing

            // Update video playback controls.
            if showVideoPlayerControls {
                // Just in case, make sure the views are properly configured after previous animations.
                videoPlaybackControlView?.animateIn()
                videoPlaybackProgressView?.animateIn()
            }
            videoPlaybackControlView?.isHidden = showVideoPlayerControls == false
            videoPlaybackProgressView?.isHidden = showVideoPlayerControls == false
            videoPlayerControlsAreaZeroHeightConstraint.isActive = showVideoPlayerControls == false
            videoPlayerControlsAreaBottomToThumbnailStripTop?.constant = videoPlayerControlsAreaBottomSpacing

            // Show / hide grouped / individual glass backgrounds.
            if #available(iOS 26, *) {
                captionAndMediaControlsGlassBackgroundView?.effect = useSharedGlassBackground ? interactiveGlassEffect() : nil

                if showCaptionView {
                    captionView.hasGlassBackground = !useSharedGlassBackground
                }

                if showVideoPlayerControls {
                    videoPlaybackProgressView?.hasGlassBackground = !useSharedGlassBackground
                }
            }

            return
        }

        //
        // Step 0. Get previous visibility states for UI elements.
        //
        let isCaptionViewVisible = captionView.isEmpty == false
        let isVideoPlayerProgressViewVisible = videoPlayerControlsAreaZeroHeightConstraint.isActive == false
        let isUsingSharedGlassBackground: Bool = {
            if #available(iOS 26, *), let captionAndMediaControlsGlassBackgroundView {
                captionAndMediaControlsGlassBackgroundView.effect != nil
            } else {
                false
            }
        }()

        //
        // Step 1. Prepare views that are hidden but will be shown:
        // • update constraints to put UI elements in their final position.
        // • unhide views but set their opacity to 0.
        // • on iOS 26 remove `liqiud glass` effect.
        //
        if showCaptionView, isCaptionViewVisible == false {
            UIView.performWithoutAnimation {
                captionView.content = captionForDisplay
                if #available(iOS 26, *) {
                    captionView.hasGlassBackground = !useSharedGlassBackground
                }
                captionView.prepareToBeAnimatedIn()
            }
        }
        if showVideoPlayerControls, isVideoPlayerProgressViewVisible == false {
            UIView.performWithoutAnimation {
                if #available(iOS 26, *) {
                    videoPlaybackProgressView?.hasGlassBackground = !useSharedGlassBackground
                }
                videoPlaybackControlView?.prepareToBeAnimatedIn()
                videoPlaybackProgressView?.prepareToBeAnimatedIn()
            }
        }
        if #available(iOS 26, *), useSharedGlassBackground, isUsingSharedGlassBackground == false {
            UIView.performWithoutAnimation {
                captionAndMediaControlsGlassBackgroundView?.effect = nil

                captionViewAreaBottomToVideoPlayerControlsAreaTop?.constant = captionViewAreaBottomSpacing
                videoPlayerControlsAreaBottomToThumbnailStripTop?.constant = videoPlayerControlsAreaBottomSpacing
            }
        }

        //
        // Step 2. Animate UI elements in or out.
        // If the view is animated out layout (constraints) will be updated once animatior completes.
        //

        // Caption view.
        if showCaptionView, isCaptionViewVisible == false {
            // No caption -> Caption
            animator.addAnimations {
                self.captionView.animateIn()

                self.captionViewAreaZeroHeightConstraint.isActive = false
                self.captionViewAreaBottomToVideoPlayerControlsAreaTop?.constant = captionViewAreaBottomSpacing
            }
        } else if showCaptionView == false, isCaptionViewVisible {
            // Caption -> No caption
            animator.addAnimations {
                self.captionView.animateOut()

                self.captionViewAreaZeroHeightConstraint.isActive = true
                self.captionViewAreaBottomToVideoPlayerControlsAreaTop?.constant = captionViewAreaBottomSpacing
            }
            animator.addCompletion { position in
                guard position == .end else { return }

                self.captionView.isHidden = true
                self.captionView.content = nil
            }
        } else {
            // Caption 1 -> Caption 2
            // No caption -> no caption: possible interface rotation - padding amount might change.
            animator.addAnimations {
                if showCaptionView {
                    self.captionView.content = captionForDisplay
                }
                self.captionViewAreaZeroHeightConstraint.isActive = showCaptionView == false
                self.captionViewAreaBottomToVideoPlayerControlsAreaTop?.constant = captionViewAreaBottomSpacing
            }
        }

        // Video player controls.
        if showVideoPlayerControls, isVideoPlayerProgressViewVisible == false {
            // No controls -> Controls
            animator.addAnimations {
                self.videoPlaybackControlView?.animateIn()
                self.videoPlaybackProgressView?.animateIn()

                self.videoPlayerControlsAreaZeroHeightConstraint.isActive = false
                self.videoPlayerControlsAreaBottomToThumbnailStripTop?.constant = videoPlayerControlsAreaBottomSpacing
            }
        } else if showVideoPlayerControls == false, isVideoPlayerProgressViewVisible {
            // Controls -> No controls
            animator.addAnimations {
                self.videoPlaybackControlView?.animateOut()
                self.videoPlaybackProgressView?.animateOut()

                self.videoPlayerControlsAreaZeroHeightConstraint.isActive = true
                self.videoPlayerControlsAreaBottomToThumbnailStripTop?.constant = videoPlayerControlsAreaBottomSpacing
            }
            animator.addCompletion { position in
                guard position == .end else { return }

                self.videoPlaybackControlView?.isHidden = true
                self.videoPlaybackProgressView?.isHidden = true
            }
        } else {
            // Possible interface rotation - padding amount might change.
            animator.addAnimations {
                self.videoPlayerControlsAreaZeroHeightConstraint.isActive = showVideoPlayerControls == false
                self.videoPlayerControlsAreaBottomToThumbnailStripTop?.constant = videoPlayerControlsAreaBottomSpacing
            }
        }

        // Shared glass background.
        if #available(iOS 26, *) {
            if useSharedGlassBackground, isUsingSharedGlassBackground == false {
                // No background -> background
                animator.addAnimations {
                    self.captionAndMediaControlsGlassBackgroundView?.effect = self.interactiveGlassEffect()
                    if showCaptionView {
                        self.captionView.hasGlassBackground = false
                    }
                    if showVideoPlayerControls {
                        self.videoPlaybackProgressView?.hasGlassBackground = false
                    }
                }
            } else if useSharedGlassBackground == false, isUsingSharedGlassBackground {
                // Background -> No background
                animator.addAnimations {
                    self.captionAndMediaControlsGlassBackgroundView?.effect = nil
                    if showCaptionView {
                        self.captionView.hasGlassBackground = true
                    }
                    if showVideoPlayerControls {
                        self.videoPlaybackProgressView?.hasGlassBackground = true
                    }
                }
            }
        }
    }

    private func updateThumbnailStripLayout(
        using animator: UIViewPropertyAnimator? = nil,
        transitionDirection: UIPageViewController.NavigationDirection? = nil,
    ) {

        let isThumbnailStripHidden = thumbnailStripAreaZeroHeightConstraint.isActive
        let showThumbnailStrip = showThumbnailStrip

        // No bottom padding in landscape because there are no controls below.
        let bottomPadding: CGFloat = {
            if isVerticallyCompactLayout {
                0
            } else if #available(iOS 26, *) {
                24
            } else {
                4
            }
        }()

        // Only animate changes if thumbnail strip appears or disappears.
        // Change from one album to another is animated by GalleryRailView.
        guard let animator, showThumbnailStrip == isThumbnailStripHidden else {
            thumbnailStrip.alpha = 1
            thumbnailStrip.transform = .identity
            thumbnailStrip.isHidden = !showThumbnailStrip

            thumbnailStripAreaZeroHeightConstraint.isActive = !showThumbnailStrip
            thumbnailStripAreaBottomToButtonsAreaTop?.constant = showThumbnailStrip ? bottomPadding : 0

            return
        }

        // Move the strip 20 pt horizontally while animating it in or out.
        // Translation shold be done in the same direction as media page scrolling.
        let animationTransform: CGAffineTransform
        if let transitionDirection {
            var offset: CGFloat = 20
            if transitionDirection == .reverse {
                offset = -offset
            }
            if showThumbnailStrip == false {
                offset = -offset
            }
            if traitCollection.layoutDirection == .rightToLeft {
                offset = -offset
            }
            animationTransform = .translate(.init(x: offset, y: 0))
        } else {
            animationTransform = .identity
        }

        if showThumbnailStrip {
            UIView.performWithoutAnimation {
                thumbnailStrip.alpha = 0
                thumbnailStrip.transform = animationTransform
                thumbnailStrip.isHidden = false
            }
            // Fade in.
            animator.addAnimations {
                self.thumbnailStrip.alpha = 1
                self.thumbnailStrip.transform = .identity

                self.thumbnailStripAreaZeroHeightConstraint.isActive = false
                self.thumbnailStripAreaBottomToButtonsAreaTop?.constant = bottomPadding
            }
        } else {
            // Fade out.
            animator.addAnimations {
                self.thumbnailStrip.alpha = 0
                self.thumbnailStrip.transform = animationTransform
            }
            animator.addCompletion { position in
                guard position == .end else { return }

                self.thumbnailStrip.alpha = 1
                self.thumbnailStrip.transform = .identity
                self.thumbnailStrip.isHidden = true

                self.thumbnailStripAreaZeroHeightConstraint.isActive = true
                self.thumbnailStripAreaBottomToButtonsAreaTop?.constant = 0
            }
        }
    }

    private func updateBottomButtonsLayout() {
        buttonForwardMedia.isHidden = isVerticallyCompactLayout
        buttonShareMedia.isHidden = isVerticallyCompactLayout
        buttonAreaZeroHeightConstraint.isActive = isVerticallyCompactLayout
    }

    // MARK: Bottom buttons

    private func didPressShare() {
        delegate?.mediaControlPanelDidRequestShareMedia(self)
    }

    private func didPressForward() {
        delegate?.mediaControlPanelDidRequestForwardMedia(self)
    }
}

extension MediaControlPanelView: VideoPlaybackStatusObserver {

    func videoPlayerStatusChanged(_ videoPlayer: VideoPlayer) {
        if let videoPlaybackControlView {
            videoPlaybackControlView.updateStatusWithPlayer(videoPlayer)
        }
    }
}

extension MediaControlPanelView: VideoPlaybackControlViewDelegate {

    func videoPlaybackControlViewDidTapPlayPause(_ videoPlaybackControlView: VideoPlaybackControlView) {
        guard let videoPlayer else { return }

        if videoPlayer.isPlaying {
            videoPlayer.pause()
        } else {
            videoPlayer.play()
        }
    }

    func videoPlaybackControlViewDidTapRewind(_ videoPlaybackControlView: VideoPlaybackControlView, duration: TimeInterval) {
        guard let videoPlayer else { return }

        videoPlayer.rewind(duration)
    }

    func videoPlaybackControlViewDidTapFastForward(_ videoPlaybackControlView: VideoPlaybackControlView, duration: TimeInterval) {
        guard let videoPlayer else { return }

        videoPlayer.fastForward(duration)
    }

    func videoPlaybackControlViewDidStartRewind(_ videoPlaybackControlView: VideoPlaybackControlView) {
        guard let videoPlayer else { return }

        videoPlayer.changePlaybackRate(to: -2)
    }

    func videoPlaybackControlViewDidStartFastForward(_ videoPlaybackControlView: VideoPlaybackControlView) {
        guard let videoPlayer else { return }

        videoPlayer.changePlaybackRate(to: 2)
    }

    func videoPlaybackControlViewDidStopRewindOrFastForward(_ videoPlaybackControlView: VideoPlaybackControlView) {
        guard let videoPlayer else { return }

        videoPlayer.restorePlaybackRate()
    }
}