278 lines
10 KiB
Swift
278 lines
10 KiB
Swift
//
|
|
// 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<Player: View, PlaybackControls: View>: 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<AnyView> = {
|
|
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<AnyView> = {
|
|
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<AnyView> = {
|
|
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<AnyCancellable> = []
|
|
|
|
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<UIPress>, 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<PressEvent>
|
|
}
|
|
|
|
@propertyWrapper
|
|
struct OnPressEvent: DynamicProperty {
|
|
|
|
@Environment(\.onPressEventPublisher)
|
|
private var publisher
|
|
|
|
var wrappedValue: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent {
|
|
publisher
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
|
|
@Entry
|
|
var onPressEventPublisher: VideoPlayer.UIVideoPlayerContainerViewController.OnPressEvent = .init()
|
|
}
|