// // Swiftfin is subject to the terms of the Mozilla Public // License, v2.0. If a copy of the MPL was not distributed with this // file, you can obtain one at https://mozilla.org/MPL/2.0/. // // Copyright (c) 2025 Jellyfin & Jellyfin Contributors // import Combine import Engine import SwiftUI extension VideoPlayer { struct VideoPlayerContainerView: UIViewControllerRepresentable { private let containerState: VideoPlayerContainerState private let manager: MediaPlayerManager private let player: () -> Player private let playbackControls: () -> PlaybackControls init( containerState: VideoPlayerContainerState, manager: MediaPlayerManager, @ViewBuilder player: @escaping () -> Player, @ViewBuilder playbackControls: @escaping () -> PlaybackControls ) { self.containerState = containerState self.manager = manager self.player = player self.playbackControls = playbackControls } func makeUIViewController(context: Context) -> UIVideoPlayerContainerViewController { UIVideoPlayerContainerViewController( containerState: containerState, manager: manager, player: player().eraseToAnyView(), playbackControls: playbackControls().eraseToAnyView() ) } func updateUIViewController( _ uiViewController: UIVideoPlayerContainerViewController, context: Context ) {} } // MARK: - UIVideoPlayerContainerViewController class UIVideoPlayerContainerViewController: UIViewController { private struct PlayerContainerView: View { @EnvironmentObject private var containerState: VideoPlayerContainerState let player: AnyView var body: some View { player .overlay(Color.black.opacity(containerState.isPresentingPlaybackControls ? 0.3 : 0.0)) .animation(.linear(duration: 0.2), value: containerState.isPresentingPlaybackControls) } } private lazy var playerViewController: HostingController = { let controller = HostingController( content: PlayerContainerView(player: player) .environmentObject(containerState) .environmentObject(manager) .eraseToAnyView() ) controller.disablesSafeArea = true controller.automaticallyAllowUIKitAnimationsForNextUpdate = true controller.view.translatesAutoresizingMaskIntoConstraints = false return controller }() private lazy var playbackControlsViewController: HostingController = { let controller = HostingController( content: playbackControls .environment(\.onPressEventPublisher, onPressEvent) .environmentObject(containerState) .environmentObject(containerState.scrubbedSeconds) .environmentObject(focusGuide) .environmentObject(manager) .eraseToAnyView() ) controller.disablesSafeArea = true controller.automaticallyAllowUIKitAnimationsForNextUpdate = true controller.view.translatesAutoresizingMaskIntoConstraints = false return controller }() private lazy var supplementContainerViewController: HostingController = { let content = SupplementContainerView() .environmentObject(containerState) .environmentObject(focusGuide) .environmentObject(manager) .eraseToAnyView() let controller = HostingController(content: content) controller.disablesSafeArea = true controller.automaticallyAllowUIKitAnimationsForNextUpdate = true controller.view.translatesAutoresizingMaskIntoConstraints = false return controller }() private var playerView: UIView { playerViewController.view } private var playbackControlsView: UIView { playbackControlsViewController.view } private var supplementContainerView: UIView { supplementContainerViewController.view } private var supplementRegularConstraints: [NSLayoutConstraint] = [] private var playerRegularConstraints: [NSLayoutConstraint] = [] private var playbackControlsConstraints: [NSLayoutConstraint] = [] private var supplementHeightAnchor: NSLayoutConstraint! private var supplementBottomAnchor: NSLayoutConstraint! private let manager: MediaPlayerManager private let player: AnyView private let playbackControls: AnyView private let containerState: VideoPlayerContainerState let focusGuide = FocusGuide() let onPressEvent = OnPressEvent() private var cancellables: Set = [] init( containerState: VideoPlayerContainerState, manager: MediaPlayerManager, player: AnyView, playbackControls: AnyView ) { self.containerState = containerState self.manager = manager self.player = player self.playbackControls = playbackControls super.init(nibName: nil, bundle: nil) containerState.containerView = self containerState.manager = manager } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - didPresent func presentSupplementContainer( _ didPresent: Bool ) { if didPresent { self.supplementBottomAnchor.constant = -(500 + EdgeInsets.edgePadding * 2) } else { self.supplementBottomAnchor.constant = -(100 + EdgeInsets.edgePadding) } containerState.isPresentingPlaybackControls = !didPresent containerState.supplementOffset = supplementBottomAnchor.constant UIView.animate( withDuration: 0.75, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.4, options: .allowUserInteraction ) { self.view.layoutIfNeeded() } } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black setupViews() setupConstraints() let gesture = UITapGestureRecognizer(target: self, action: #selector(ignorePress)) gesture.allowedPressTypes = [NSNumber(value: UIPress.PressType.menu.rawValue)] view.addGestureRecognizer(gesture) } private func setupViews() { addChild(playerViewController) view.addSubview(playerView) playerViewController.didMove(toParent: self) playerView.backgroundColor = .black addChild(playbackControlsViewController) view.addSubview(playbackControlsView) playbackControlsViewController.didMove(toParent: self) playbackControlsView.backgroundColor = .clear addChild(supplementContainerViewController) view.addSubview(supplementContainerView) supplementContainerViewController.didMove(toParent: self) supplementContainerView.backgroundColor = .clear } private func setupConstraints() { playerRegularConstraints = [ playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), playerView.topAnchor.constraint(equalTo: view.topAnchor), playerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ] NSLayoutConstraint.activate(playerRegularConstraints) supplementBottomAnchor = supplementContainerView.topAnchor.constraint( equalTo: view.bottomAnchor, constant: -(100 + EdgeInsets.edgePadding) ) containerState.supplementOffset = supplementBottomAnchor.constant let constant = (500 + EdgeInsets.edgePadding * 2) supplementHeightAnchor = supplementContainerView.heightAnchor.constraint(equalToConstant: constant) supplementRegularConstraints = [ supplementContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), supplementContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), supplementBottomAnchor, supplementHeightAnchor, ] NSLayoutConstraint.activate(supplementRegularConstraints) playbackControlsConstraints = [ playbackControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), playbackControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), playbackControlsView.topAnchor.constraint(equalTo: view.topAnchor), playbackControlsView.bottomAnchor.constraint(equalTo: supplementContainerView.topAnchor), ] NSLayoutConstraint.activate(playbackControlsConstraints) } @objc func ignorePress() {} override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { print(presses) guard let buttonPress = presses.first else { return } onPressEvent.send((type: buttonPress.type, phase: buttonPress.phase)) } } } extension VideoPlayer.UIVideoPlayerContainerViewController { typealias PressEvent = (type: UIPress.PressType, phase: UIPress.Phase) typealias OnPressEvent = LegacyEventPublisher } @propertyWrapper struct OnPressEvent: DynamicProperty { @Environment(\.onPressEventPublisher) private var publisher var wrappedValue: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent { publisher } } extension EnvironmentValues { @Entry var onPressEventPublisher: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent = .init() }