jellyflood/Swiftfin/Views/VideoPlayerContainerView/VideoPlayerContainerView.swift

625 lines
26 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 Defaults
import Engine
import Logging
import SwiftUI
// TODO: don't dismiss overlay while panning and supplement not presented
// TODO: use video size from proxies to control aspect fill
// - stay within safe areas, aspect fill to screen
// TODO: instead of static sizes for supplement view, take into account available space
// - necessary for full-screen supplements and/or small screens
// TODO: custom buttons on playback controls
// - skip intro, next episode, etc.
// - can just do on playback controls itself
// TODO: pass in safe area insets explicitly?
// TODO: pause when center tapped when overlay dismissed
// - can be done entirely on playback controls layer
// TODO: no supplements state
// - don't pan
// TODO: account for gesture state active when item changes
// TODO: only show player view if not error/other bad states
// - only show when have item?
// - helps with not rendering before ready
// - would require refactor so that video players take media player items
// MARK: - VideoPlayerContainerView
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 {
let playerView = player()
.environment(\.audioOffset, context.environment.audioOffset)
.eraseToAnyView()
let playbackControlsView = playbackControls()
.environment(\.audioOffset, context.environment.audioOffset)
.eraseToAnyView()
return UIVideoPlayerContainerViewController(
containerState: containerState,
manager: manager,
player: playerView,
playbackControls: playbackControlsView
)
}
func updateUIViewController(
_ uiViewController: UIVideoPlayerContainerViewController,
context: Context
) {}
}
// MARK: - UIVideoPlayerContainerViewController
class UIVideoPlayerContainerViewController: UIViewController {
// MARK: - Views
// TODO: preview image while scrubbing option
private struct PlayerContainerView: View {
@EnvironmentObject
private var containerState: VideoPlayerContainerState
let player: AnyView
private var shouldPresentDimOverlay: Bool {
if containerState.isScrubbing {
return false
}
if containerState.isCompact {
return containerState.isPresentingPlaybackControls
} else {
return containerState.isPresentingOverlay
}
}
var body: some View {
player
.overlay(Color.black.opacity(shouldPresentDimOverlay ? 0.5 : 0.0))
.animation(.linear(duration: 0.2), value: containerState.isPresentingPlaybackControls)
}
}
private struct PlaybackControlsContainerView: View {
@Default(.VideoPlayer.Gesture.horizontalSwipeAction)
private var horizontalSwipeAction
@EnvironmentObject
private var containerState: VideoPlayerContainerState
let playbackControls: AnyView
var body: some View {
OverlayToastView(proxy: containerState.toastProxy) {
ZStack {
GestureView()
.environment(
\.panGestureDirection,
containerState.presentationControllerShouldDismiss ? .allButDown : .vertical
)
playbackControls
}
// inject box explicitly
.environmentObject(containerState.scrubbedSeconds)
}
.environment(
\.longPressAction,
.init(
action: {
containerState.containerView?.handleLongPressGesture(
location: $0,
unitPoint: $1,
state: $2
)
}
)
)
.environment(
\.panAction,
.init(
action: {
containerState.containerView?.handlePanGesture(
translation: $0,
velocity: $1,
location: $2,
unitPoint: $3,
state: $4
)
}
)
)
.environment(
\.pinchAction,
.init(
action: {
containerState.containerView?.handlePinchGesture(scale: $0, velocity: $1, state: $2)
}
)
)
.environment(
\.tapGestureAction,
.init(
action: {
containerState.containerView?.handleTapGesture(
location: $0,
unitPoint: $1,
count: $2
)
}
)
)
}
}
private lazy var initialHitBlockView: UIView = {
let view = UIView(frame: .zero)
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
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: PlaybackControlsContainerView(playbackControls: playbackControls)
.environmentObject(containerState)
.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(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 }
// MARK: - Constants
private let compactSupplementContainerOffset: (CGFloat) -> CGFloat = { totalHeight in
max(totalHeight * 0.6, 300) + EdgeInsets.edgePadding * 2
}
private let regularSupplementContainerOffset: CGFloat = 200.0 + EdgeInsets.edgePadding * 2
private let dismissedSupplementContainerOffset: CGFloat = 50.0 + EdgeInsets.edgePadding * 2
private let compactMinimumTranslation: CGFloat = 100.0
private let regularMinimumTranslation: CGFloat = 50.0
// MARK: - Constraints
private var playbackControlsConstraints: [NSLayoutConstraint] = []
private var playerCompactConstraints: [NSLayoutConstraint] = []
private var playerRegularConstraints: [NSLayoutConstraint] = []
private var supplementContainerConstraints: [NSLayoutConstraint] = []
private var playerCompactBottomAnchor: NSLayoutConstraint!
private var supplementHeightAnchor: NSLayoutConstraint!
private var supplementBottomAnchor: NSLayoutConstraint!
private var centerOffset: CGFloat {
guard containerState.isCompact else {
return dismissedSupplementContainerOffset
}
let supplementContainerHeight = compactSupplementContainerOffset(view.bounds.height)
let offsetPercentage = 1 - clamp(supplementBottomAnchor.constant.magnitude / supplementContainerHeight, min: 0, max: 1)
let offset = (dismissedSupplementContainerOffset + EdgeInsets.edgePadding) * offsetPercentage
return max(50, offset)
}
private var compactPlayerBottomOffset: CGFloat {
guard containerState.isCompact else {
return dismissedSupplementContainerOffset
}
let supplementContainerHeight = compactSupplementContainerOffset(view.bounds.height)
let offsetPercentage = 1 - clamp(supplementBottomAnchor.constant.magnitude / supplementContainerHeight, min: 0, max: 1)
let offset = (dismissedSupplementContainerOffset + EdgeInsets.edgePadding) * offsetPercentage
return offset
}
private let logger = Logger.swiftfin()
private let manager: MediaPlayerManager
private let player: AnyView
private let playbackControls: AnyView
let containerState: VideoPlayerContainerState
private var cancellables: Set<AnyCancellable> = []
private var didInitiallyAppear: Bool = false
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")
}
// TODO: don't force unwrap optional, sometimes gets into weird state
private var lastVerticalPanLocation: CGPoint?
private var verticalPanGestureStartConstant: CGFloat?
private var isPanning: Bool = false
private var didStartPanningWithSupplement: Bool = false
private var didStartPanningUpWithoutOverlay: Bool = false
// MARK: - Supplement Pan Action
func handleSupplementPanAction(
translation: CGPoint,
velocity: CGFloat,
location: CGPoint,
state: UIGestureRecognizer.State
) {
let yDirection: CGFloat = translation.y > 0 ? -1 : 1
let newOffset: CGFloat
let clampedOffset: CGFloat
if state == .began {
self.view.layer.removeAllAnimations()
didStartPanningWithSupplement = containerState.selectedSupplement != nil
verticalPanGestureStartConstant = supplementBottomAnchor.constant
didStartPanningUpWithoutOverlay = !containerState.isPresentingOverlay
if didStartPanningUpWithoutOverlay {
containerState.isPresentingOverlay = true
}
}
if state == .began || state == .changed {
lastVerticalPanLocation = location
isPanning = true
let minimumTranslation =
-((containerState.isCompact ? compactMinimumTranslation : regularMinimumTranslation) +
dismissedSupplementContainerOffset
)
let shouldHaveSupplementPresented = self.supplementBottomAnchor.constant < minimumTranslation
if shouldHaveSupplementPresented, !containerState.isPresentingSupplement {
containerState.selectedSupplement = manager.supplements.first
} else if !shouldHaveSupplementPresented, containerState.selectedSupplement != nil {
containerState.selectedSupplement = nil
}
} else {
lastVerticalPanLocation = nil
verticalPanGestureStartConstant = nil
isPanning = false
let translationMin: CGFloat = containerState.isCompact ? compactMinimumTranslation : regularMinimumTranslation
let shouldActuallyDismissSupplement = didStartPanningWithSupplement && (translation.y > translationMin || velocity > 1000)
if shouldActuallyDismissSupplement {
// If we started with a supplement and panned down more than 100 points, dismiss it
containerState.selectedSupplement = nil
}
let shouldActuallyPresentSupplement = !didStartPanningWithSupplement &&
(translation.y < -translationMin || velocity < -1000)
if shouldActuallyPresentSupplement {
// If we didn't start with a supplement and panned up more than 100 points, present it
containerState.selectedSupplement = manager.supplements.first
}
let stateToPass: (translation: CGFloat, velocity: CGFloat)? = lastVerticalPanLocation != nil &&
verticalPanGestureStartConstant !=
nil ?
(translation: translation.y, velocity: velocity) : nil
presentSupplementContainer(containerState.selectedSupplement != nil, with: stateToPass)
let shouldActuallyDismissOverlay = didStartPanningUpWithoutOverlay && !containerState.isPresentingSupplement
if shouldActuallyDismissOverlay {
containerState.isPresentingOverlay = false
}
return
}
guard let verticalPanGestureStartConstant else {
logger.error("Vertical pan gesture invalid state: verticalPanGestureStartConstant is nil")
return
}
if (!didStartPanningWithSupplement && yDirection > 0) || (didStartPanningWithSupplement && yDirection < 0) {
// If we started with a supplement and are panning down, or if we didn't start with a supplement and are panning up
newOffset = verticalPanGestureStartConstant + (translation.y.magnitude * -yDirection)
} else {
newOffset = verticalPanGestureStartConstant - (translation.y.magnitude * yDirection)
}
if containerState.isCompact {
clampedOffset = clamp(
newOffset,
min: -compactSupplementContainerOffset(view.bounds.height),
max: -dismissedSupplementContainerOffset
)
} else {
clampedOffset = clamp(
newOffset,
min: -regularSupplementContainerOffset,
max: -dismissedSupplementContainerOffset
)
}
if newOffset < clampedOffset {
let excess = clampedOffset - newOffset
let resistance = pow(excess, 0.7)
supplementBottomAnchor.constant = clampedOffset - resistance
} else if newOffset > -dismissedSupplementContainerOffset {
let excess = newOffset - clampedOffset
let resistance = pow(excess, 0.5)
supplementBottomAnchor.constant = clamp(clampedOffset + resistance, min: -dismissedSupplementContainerOffset, max: -50)
} else {
supplementBottomAnchor.constant = clampedOffset
}
playerCompactBottomAnchor.constant = compactPlayerBottomOffset
containerState.supplementOffset = supplementBottomAnchor.constant
containerState.centerOffset = centerOffset
}
// MARK: - present
func presentSupplementContainer(
_ didPresent: Bool,
with panningState: (translation: CGFloat, velocity: CGFloat)? = nil
) {
guard !isPanning else { return }
if didPresent {
if containerState.isCompact {
self.supplementBottomAnchor.constant = -compactSupplementContainerOffset(view.bounds.size.height)
} else {
self.supplementBottomAnchor.constant = -regularSupplementContainerOffset
}
} else {
self.supplementBottomAnchor.constant = -dismissedSupplementContainerOffset
}
playerCompactBottomAnchor.constant = compactPlayerBottomOffset
containerState.supplementOffset = supplementBottomAnchor.constant
containerState.centerOffset = centerOffset
if let panningState {
let velocity = panningState.velocity.magnitude / 1000
let distance = panningState.translation.magnitude
let duration = min(max(Double(distance) / Double(velocity * 1000), 0.2), 0.75)
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: velocity,
options: .allowUserInteraction
) { [weak self] in
self?.view.layoutIfNeeded()
}
} else {
UIView.animate(
withDuration: containerState.isCompact ? 0.75 : 0.6,
delay: 0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.4,
options: .allowUserInteraction
) { [weak self] in
self?.view.layoutIfNeeded()
}
}
}
// MARK: - viewDidAppear
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !didInitiallyAppear {
containerState.isPresentingOverlay = true
setupPlayerView()
initialHitBlockView.removeFromSuperview()
didInitiallyAppear = true
}
}
// MARK: - viewDidLoad
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
containerState.isCompact = UIDevice.isPhone && view.bounds.size.isPortrait
setupOnLoadViews()
setupOnLoadConstraints()
}
// Setup player view separately after view appears to hopefully
// prevent player playing before the view is done presenting
private func setupPlayerView() {
addChild(playerViewController)
view.addSubview(playerView)
view.sendSubviewToBack(playerView)
playerViewController.didMove(toParent: self)
playerView.backgroundColor = .black
playerCompactBottomAnchor = playerView.bottomAnchor.constraint(
equalTo: supplementContainerView.topAnchor,
constant: compactPlayerBottomOffset
)
playerCompactConstraints = [
playerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
playerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
playerView.topAnchor.constraint(equalTo: view.topAnchor),
playerCompactBottomAnchor,
]
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),
]
if containerState.isCompact {
NSLayoutConstraint.activate(playerCompactConstraints)
} else {
NSLayoutConstraint.activate(playerRegularConstraints)
}
}
private func setupOnLoadViews() {
addChild(playbackControlsViewController)
view.addSubview(playbackControlsView)
playbackControlsViewController.didMove(toParent: self)
playbackControlsView.backgroundColor = .clear
addChild(supplementContainerViewController)
view.addSubview(supplementContainerView)
supplementContainerViewController.didMove(toParent: self)
supplementContainerView.backgroundColor = .clear
view.addSubview(initialHitBlockView)
view.bringSubviewToFront(initialHitBlockView)
}
private func setupOnLoadConstraints() {
let isCompact = UIDevice.isPhone && view.bounds.size.isPortrait
supplementBottomAnchor = supplementContainerView.topAnchor.constraint(
equalTo: view.bottomAnchor,
constant: -dismissedSupplementContainerOffset
)
containerState.supplementOffset = supplementBottomAnchor.constant
containerState.centerOffset = centerOffset
let constant = isCompact ?
compactSupplementContainerOffset(view.bounds.height) :
regularSupplementContainerOffset
supplementHeightAnchor = supplementContainerView.heightAnchor.constraint(equalToConstant: constant)
supplementContainerConstraints = [
supplementContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
supplementContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
supplementBottomAnchor,
supplementHeightAnchor,
]
NSLayoutConstraint.activate(supplementContainerConstraints)
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)
NSLayoutConstraint.activate([
initialHitBlockView.topAnchor.constraint(equalTo: view.topAnchor),
initialHitBlockView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
initialHitBlockView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
initialHitBlockView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
adjustContraints(isCompact: UIDevice.isPhone && size.isPortrait, in: size)
}
private func adjustContraints(isCompact: Bool, in newSize: CGSize) {
containerState.isCompact = isCompact
if isCompact {
NSLayoutConstraint.deactivate(playerRegularConstraints)
NSLayoutConstraint.activate(playerCompactConstraints)
supplementBottomAnchor.constant = containerState
.isPresentingSupplement ? -compactSupplementContainerOffset(newSize.height) : -dismissedSupplementContainerOffset
supplementHeightAnchor.constant = compactSupplementContainerOffset(newSize.height)
} else {
NSLayoutConstraint.deactivate(playerCompactConstraints)
NSLayoutConstraint.activate(playerRegularConstraints)
supplementBottomAnchor.constant = containerState
.isPresentingSupplement ? -regularSupplementContainerOffset : -dismissedSupplementContainerOffset
supplementHeightAnchor.constant = regularSupplementContainerOffset
}
playerCompactBottomAnchor.constant = compactPlayerBottomOffset
containerState.supplementOffset = supplementHeightAnchor.constant
containerState.centerOffset = centerOffset
}
}
}