yattee/Shared/Player/VideoPlayerView.swift
Toni Förster 3da081b40c
small delay before vertical scrubbing is possible
This avoids accidentally scrubbing. The screen now needs to be touched at least 250 ms before time scrubbing is possible.

should fix #393
2024-05-01 19:11:42 +02:00

518 lines
18 KiB
Swift

import AVKit
#if os(iOS)
import CoreMotion
#endif
import Defaults
import Repeat
import Siesta
import SwiftUI
struct VideoPlayerView: View {
#if os(iOS)
static let hiddenOffset = UIScreen.main.bounds.height
static let defaultSidebarQueueValue = UIScreen.main.bounds.width > 900 && Defaults[.playerSidebar] == .whenFits
#else
static let defaultSidebarQueueValue = Defaults[.playerSidebar] != .never
#endif
#if os(macOS)
static let hiddenOffset = 0.0
#endif
static let defaultAspectRatio = 16 / 9.0
static var defaultMinimumHeightLeft: Double {
#if os(macOS)
335
#else
200
#endif
}
@State private var playerSize: CGSize = .zero { didSet { updateSidebarQueue() } }
@State private var hoveringPlayer = false
@State private var fullScreenDetails = false
@State private var sidebarQueue = defaultSidebarQueueValue
@Environment(\.colorScheme) private var colorScheme
#if os(iOS)
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
private var orientationModel = OrientationModel.shared
#elseif os(macOS)
var hoverThrottle = Throttle(interval: 0.5)
var mouseLocation: CGPoint { NSEvent.mouseLocation }
#endif
#if !os(tvOS)
@GestureState var dragGestureState = false
@GestureState var dragGestureOffset = CGSize.zero
@GestureState var isLongPressing = false
// swiftlint:disable swiftui_state_private
@State var isHorizontalDrag = false
@State var isVerticalDrag = false
@State var viewDragOffset = Self.hiddenOffset
@State var startTouchTime: Date? = nil
// swiftlint:enable swiftui_state_private
#endif
@ObservedObject var player = PlayerModel.shared // swiftlint:disable:this swiftui_state_private
#if os(macOS)
@ObservedObject private var navigation = NavigationModel.shared
#endif
@Default(.horizontalPlayerGestureEnabled) var horizontalPlayerGestureEnabled
@Default(.seekGestureSpeed) var seekGestureSpeed
@Default(.seekGestureSensitivity) var seekGestureSensitivity
@Default(.playerSidebar) var playerSidebar
@Default(.gestureBackwardSeekDuration) private var gestureBackwardSeekDuration
@Default(.gestureForwardSeekDuration) private var gestureForwardSeekDuration
@Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls
@ObservedObject var controlsOverlayModel = ControlOverlaysModel.shared // swiftlint:disable:this swiftui_state_private
var body: some View {
ZStack(alignment: overlayAlignment) {
videoPlayer
.zIndex(-1)
#if os(iOS)
.gesture(controlsOverlayModel.presenting ? videoPlayerCloseControlsOverlayGesture : nil)
#endif
overlay
}
.onAppear {
if player.musicMode {
player.backend.startControlsUpdates()
}
updateSidebarQueue()
}
.onChange(of: playerSidebar) { _ in
updateSidebarQueue()
}
}
var videoPlayer: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
content
.onAppear {
playerSize = geometry.size
}
}
.ignoresSafeArea(.all, edges: .bottom)
#if os(iOS)
.frame(height: playerHeight.isNil ? nil : Double(playerHeight!))
#endif
.onChange(of: geometry.size) { _ in
self.playerSize = geometry.size
}
.onChange(of: fullScreenDetails) { value in
player.backend.setNeedsDrawing(!value)
}
#if os(iOS)
.onChange(of: player.presentingPlayer) { newValue in
if newValue {
viewDragOffset = 0
}
}
.onAppear {
#if os(macOS)
if player.videoForDisplay.isNil {
player.hide()
}
#endif
viewDragOffset = 0
Delay.by(0.2) {
orientationModel.configureOrientationUpdatesBasedOnAccelerometer()
if let orientationMask = player.lockedOrientation {
Orientation.lockOrientation(
orientationMask,
andRotateTo: orientationMask == .landscapeLeft ? .landscapeLeft : orientationMask == .landscapeRight ? .landscapeRight : .portrait
)
} else {
Orientation.lockOrientation(.allButUpsideDown)
}
}
}
.onAnimationCompleted(for: viewDragOffset) {
guard !dragGestureState else { return }
if viewDragOffset == 0 {
player.onPresentPlayer.forEach { $0() }
player.onPresentPlayer = []
} else if viewDragOffset == Self.hiddenOffset {
player.hide(animate: false)
}
}
#endif
}
#if os(iOS)
.onChange(of: dragGestureState) { newValue in
guard !newValue else { return }
onPlayerDragGestureEnded()
}
.offset(y: playerOffset)
.animation(dragGestureState ? .interactiveSpring(response: 0.05) : .easeOut(duration: 0.2), value: playerOffset)
.backport
.persistentSystemOverlays(!fullScreenPlayer)
#endif
#if os(macOS)
.frame(minWidth: playerSidebar != .never ? 1100 : 650, minHeight: 700)
#endif
}
func updateSidebarQueue() {
#if os(iOS)
sidebarQueue = playerSize.width > 900 && playerSidebar == .whenFits
#elseif os(macOS)
sidebarQueue = playerSidebar != .never
#endif
}
var overlay: some View {
VStack {
if controlsOverlayModel.presenting {
HStack {
HStack {
ControlsOverlay()
#if os(tvOS)
.onExitCommand {
withAnimation(PlayerControls.animation) {
player.controls.hideOverlays()
}
}
.onPlayPauseCommand {
player.togglePlay()
}
#endif
.padding()
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
}
#if !os(tvOS)
.frame(maxWidth: fullScreenPlayer ? .infinity : player.playerSize.width)
#endif
#if !os(tvOS)
if !fullScreenPlayer, sidebarQueue {
Spacer()
}
#endif
}
#if os(tvOS)
.clipShape(RoundedRectangle(cornerRadius: 10))
#endif
.zIndex(1)
.transition(.opacity)
}
}
}
var overlayWidth: Double {
guard playerSize.width.isFinite else { return 200 }
return [playerSize.width - 50, 250].min()!
}
var overlayAlignment: Alignment {
#if os(tvOS)
return .bottomTrailing
#else
return .top
#endif
}
#if os(iOS)
var videoPlayerCloseControlsOverlayGesture: some Gesture {
TapGesture().onEnded {
withAnimation(PlayerControls.animation) {
player.controls.hideOverlays()
}
}
}
var playerOffset: Double {
dragGestureState && !isHorizontalDrag ? dragGestureOffset.height : dragOffset
}
var dragOffset: Double {
if viewDragOffset.isZero || viewDragOffset == Self.hiddenOffset {
return viewDragOffset
}
return player.presentingPlayer ? 0 : Self.hiddenOffset
}
var playerHeight: Double? {
let lockedPortrait = player.lockedOrientation?.contains(.portrait) ?? false
let isPortrait = OrientationTracker.shared.currentInterfaceOrientation.isPortrait || lockedPortrait
return fullScreenPlayer ? UIScreen.main.bounds.size.height - (isPortrait ? safeAreaModel.safeArea.top + safeAreaModel.safeArea.bottom : 0) : nil
}
#endif
var content: some View {
Group {
ZStack(alignment: .bottomLeading) {
#if os(tvOS)
ZStack {
player.playerBackendView
if player.activeBackend == .mpv {
tvControls
}
}
.ignoresSafeArea()
#else
GeometryReader { geometry in
player.playerBackendView
.modifier(
VideoPlayerSizeModifier(
geometry: geometry,
aspectRatio: player.aspectRatio,
fullScreen: fullScreenPlayer,
detailsHiddenInFullScreen: detailsHiddenInFullScreen
)
)
.onHover { hovering in
hoveringPlayer = hovering
if hovering {
player.controls.show()
} else {
player.controls.hide()
}
}
.gesture(player.controls.presentingOverlays ? nil : playerDragGesture)
#if os(macOS)
.onAppear(perform: {
NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) {
hoverThrottle.execute {
if !player.currentItem.isNil, hoveringPlayer {
player.controls.resetTimer()
}
}
return $0
}
})
#endif
.background(Color.black)
if !detailsHiddenInFullScreen {
VideoDetails(
video: player.videoForDisplay,
fullScreen: $fullScreenDetails,
sidebarQueue: $sidebarQueue
)
.modifier(VideoDetailsPaddingModifier(
playerSize: player.playerSize,
fullScreen: fullScreenDetails
))
.onDisappear {
if player.presentingPlayer {
player.setNeedsDrawing(true)
}
}
.id(player.currentVideo?.cacheKey)
.transition(.opacity)
} else {
VStack {}
}
}
#endif
}
#if os(iOS)
.background(BackgroundBlackView().edgesIgnoringSafeArea(.all))
#endif
.background(((colorScheme == .dark || fullScreenPlayer) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
#if os(macOS)
.frame(minWidth: 650)
#endif
#if os(tvOS)
.onMoveCommand { direction in
if direction == .up {
player.controls.show()
} else if direction == .down, !controlsOverlayModel.presenting, !player.controls.presentingControls {
withAnimation(PlayerControls.animation) {
controlsOverlayModel.hide()
}
}
player.controls.resetTimer()
guard !player.controls.presentingControls else { return }
if direction == .left {
let interval = TimeInterval(gestureBackwardSeekDuration) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(-interval), seekType: .userInteracted)
}
if direction == .right {
let interval = TimeInterval(gestureForwardSeekDuration) ?? 10
player.backend.seek(relative: .secondsInDefaultTimescale(interval), seekType: .userInteracted)
}
}
.onPlayPauseCommand {
player.togglePlay()
}
.onExitCommand {
if player.controls.presentingOverlays {
player.controls.hideOverlays()
}
if player.controls.presentingControls {
player.controls.hide()
} else {
player.hide()
}
}
#endif
if !detailsHiddenInFullScreen {
#if os(iOS)
if sidebarQueue {
List {
PlayerQueueView(sidebarQueue: true)
}
#if os(macOS)
.listStyle(.inset)
#elseif os(iOS)
.listStyle(.grouped)
.backport
.scrollContentBackground(false)
#else
.listStyle(.plain)
#endif
.frame(maxWidth: 350)
.background((colorScheme == .dark ? Color.black : Color.white).ignoresSafeArea())
.transition(.move(edge: .bottom))
}
#elseif os(macOS)
if Defaults[.playerSidebar] != .never {
List {
PlayerQueueView(sidebarQueue: true)
}
.frame(maxWidth: 450)
.background(colorScheme == .dark ? Color.black : Color.white)
}
#endif
} else {
VStack {}
}
}
.onChange(of: fullScreenPlayer) { newValue in
if !newValue { player.controls.hideOverlays() }
}
#if os(iOS)
.statusBar(hidden: fullScreenPlayer)
.backport
.toolbarBackground(colorScheme == .light ? .white : .black)
.backport
.toolbarBackgroundVisibility(true)
.backport
.toolbarColorScheme(colorScheme)
#endif
#if os(macOS)
.background(
EmptyView().sheet(isPresented: $navigation.presentingPlaybackSettings) {
PlaybackSettings()
}
)
#endif
}
var detailsHiddenInFullScreen: Bool {
guard fullScreenPlayer else { return false }
if player.activeBackend == .mpv {
return true
}
#if os(iOS)
return !avPlayerUsesSystemControls || verticalSizeClass == .compact
#else
return !avPlayerUsesSystemControls
#endif
}
var fullScreenPlayer: Bool {
#if os(iOS)
player.playingFullScreen || verticalSizeClass == .compact
#elseif os(macOS)
player.playingFullScreen
#elseif os(tvOS)
true
#endif
}
@ViewBuilder var playerPlaceholder: some View {
if player.currentItem.isNil {
ZStack(alignment: .topTrailing) {
HStack {
Spacer()
VStack {
Spacer()
VStack(spacing: 10) {
#if !os(tvOS)
Image(systemName: "ticket")
.font(.system(size: 120))
#endif
}
Spacer()
}
.foregroundColor(.gray)
Spacer()
}
#if os(iOS)
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0, blendDuration: 0)) {
viewDragOffset = Self.hiddenOffset
}
} label: {
Image(systemName: "xmark")
.font(.system(size: 40))
}
.opacity(fullScreenPlayer ? 1 : 0)
.buttonStyle(.plain)
.padding(10)
.foregroundColor(.gray)
#endif
}
.background(colorScheme == .dark ? Color.black : .white)
.contentShape(Rectangle())
.frame(width: player.playerSize.width, height: player.playerSize.height)
}
}
#if os(tvOS)
var tvControls: some View {
TVControls()
}
#endif
}
struct VideoPlayerView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
Color.red
VideoPlayerView()
}
}
}
#if os(iOS)
struct BackgroundBlackView: UIViewRepresentable {
func makeUIView(context _: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async {
view.superview?.superview?.backgroundColor = .black
view.superview?.superview?.layer.removeAllAnimations()
}
return view
}
func updateUIView(_: UIView, context _: Context) {}
}
#endif