jellyflood/Swiftfin/Views/VideoPlayerContainerView/Gestures/VideoPlayerContainerView+Pa...

332 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 Defaults
import MediaPlayer
import SwiftUI
import UIKit
extension VideoPlayer.UIVideoPlayerContainerViewController {
func handlePanGesture(
translation: CGPoint,
velocity: CGPoint,
location: CGPoint,
unitPoint: UnitPoint,
state: UIGestureRecognizer.State
) {
guard checkGestureLock() else { return }
if state == .began {
containerState.timer.stop()
}
if state == .ended {
containerState.timer.poke()
}
if containerState.isPresentingSupplement {
handleSupplementPanAction(
translation: translation,
velocity: velocity.y,
location: location,
state: state
)
return
}
let direction: Direction = {
// Prioritize horizontal detection just a bit more
if velocity.y.magnitude < velocity.x.magnitude + 20 {
return velocity.x > 0 ? .right : .left
}
return velocity.y > 0 ? .down : .up
}()
let handlingState: _PanHandlingState = .init(
translation: translation,
velocity: velocity,
location: location,
unitPoint: unitPoint,
gestureState: state
)
if Defaults[.VideoPlayer.Gesture.horizontalSwipeAction] != .none, direction.isHorizontal {
if !containerState.didSwipe,
max(velocity.x.magnitude, velocity.y.magnitude) >= 1200,
max(translation.x.magnitude, translation.y.magnitude) >= 80
{
handleSwipeAction(direction: direction)
containerState.didSwipe = true
}
if state == .ended {
containerState.didSwipe = false
}
return
}
if state == .began {
let newAction = makePanHandlingAction(
direction: direction,
location: location,
unitPoint: unitPoint
)
containerState.panHandlingAction = newAction
}
if let currentAction = containerState.panHandlingAction {
unpackAndHandlePan(
handlingState: handlingState,
action: currentAction
)
}
guard state != .ended else {
containerState.panHandlingAction = nil
return
}
}
private func unpackAndHandlePan<Handler: _PanHandlingAction>(
handlingState: _PanHandlingState,
action: Handler
) {
action.onChange(
action.startState,
handlingState,
containerState
)
}
private func makePanHandlingAction(
direction: Direction,
location: CGPoint,
unitPoint: UnitPoint
) -> any _PanHandlingAction {
let newAction: any _PanHandlingAction = {
if direction.isVertical {
if unitPoint.x < 0.5 {
panActionForGestureAction(
for: Defaults[.VideoPlayer.Gesture.verticalPanLeftAction]
)
} else {
panActionForGestureAction(
for: Defaults[.VideoPlayer.Gesture.verticalPanRightAction]
)
}
} else {
panActionForGestureAction(
for: Defaults[.VideoPlayer.Gesture.horizontalPanAction]
)
}
}()
func unpackAndSetStartState<Handler: _PanHandlingAction>(
action: Handler
) -> Handler {
var action = action
action.startState = _PanStartHandlingState(
direction: direction,
location: location,
startedWithOverlay: containerState.isPresentingOverlay,
value: action.startValue(containerState)
)
return action
}
return unpackAndSetStartState(action: newAction)
}
private func panActionForGestureAction(for gestureAction: PanGestureAction) -> any _PanHandlingAction {
let isLiveStream = containerState.manager?.item.isLiveStream == true
switch (gestureAction, isLiveStream) {
case (.none, _), (.scrub, true), (.slowScrub, true):
return Self.SupplementPanHandlingAction
case (.brightness, _):
return Self.BrightnessPanHandlingAction
case (.scrub, false):
return Self.ScrubPanHandlingAction()
case (.slowScrub, false):
return Self.ScrubPanHandlingAction(damping: 0.1)
case (.volume, _):
return Self.VolumePanHandlingAction
}
}
private func handleSwipeAction(direction: Direction) {
guard containerState.manager?.item.isLiveStream == false else { return }
let jumpProgressObserver = containerState.jumpProgressObserver
if direction == .left {
let interval = Defaults[.VideoPlayer.jumpBackwardInterval]
containerState.manager?.proxy?.jumpBackward(interval.rawValue)
jumpProgressObserver.jumpBackward()
containerState.toastProxy.present(
Text(
interval.rawValue * jumpProgressObserver.jumps,
format: .minuteSecondsNarrow
),
systemName: "gobackward"
)
} else if direction == .right {
let interval = Defaults[.VideoPlayer.jumpForwardInterval]
containerState.manager?.proxy?.jumpForward(interval.rawValue)
jumpProgressObserver.jumpForward()
containerState.toastProxy.present(
Text(
interval.rawValue * jumpProgressObserver.jumps,
format: .minuteSecondsNarrow
),
systemName: "goforward"
)
}
}
}
// MARK: - Pan actions
extension VideoPlayer.UIVideoPlayerContainerViewController {
// MARK: - Brightness
private static var BrightnessPanHandlingAction: PanHandlingAction<CGFloat> {
PanHandlingAction<CGFloat>(
startValue: UIScreen.main.brightness
) { startState, handlingState, containerState in
guard handlingState.gestureState != .ended else { return }
let translation: CGFloat = {
if startState.direction.isHorizontal {
handlingState.translation.x
} else {
-handlingState.translation.y
}
}()
let newBrightness = clamp(
startState.value + CGFloat(translation / 300),
min: 0,
max: 1
)
containerState.toastProxy.present(
Text(newBrightness, format: .percent.precision(.fractionLength(0))),
systemName: "sun.max.fill"
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
UIScreen.main.brightness = newBrightness
}
}
}
// MARK: - Scrub
private static func ScrubPanHandlingAction(
damping: CGFloat = 1
) -> PanHandlingAction<Duration> {
PanHandlingAction<Duration>(
startValue: { containerState in
containerState.scrubbedSeconds.value
}
) { startState, handlingState, containerState in
if handlingState.gestureState == .ended {
containerState.isScrubbing = false
if !startState.startedWithOverlay {
containerState.isPresentingOverlay = false
}
return
}
guard let runtime = containerState.manager?.item.runtime else { return }
let translation: CGFloat = {
if startState.direction.isHorizontal {
handlingState.translation.x
} else {
-handlingState.translation.y
}
}()
let totalSize: CGFloat = {
if startState.direction.isHorizontal {
handlingState.location.x / handlingState.unitPoint.x
} else {
handlingState.location.y / handlingState.unitPoint.y
}
}()
containerState.isScrubbing = true
containerState.isPresentingOverlay = true
let newSeconds = clamp(
startState.value.seconds + (translation / totalSize) * runtime.seconds * damping,
min: 0,
max: runtime.seconds
)
let newSecondsDuration = Duration.seconds(newSeconds)
containerState.scrubbedSeconds.value = newSecondsDuration
}
}
// MARK: - Supplement
private static var SupplementPanHandlingAction: PanHandlingAction<CGFloat> {
PanHandlingAction<CGFloat>(
startValue: 0
) { _, handlingState, containerState in
containerState.containerView?.handleSupplementPanAction(
translation: handlingState.translation,
velocity: handlingState.velocity.y,
location: handlingState.location,
state: handlingState.gestureState
)
}
}
// MARK: - Volume
private static var VolumePanHandlingAction: PanHandlingAction<Float> {
PanHandlingAction<Float>(
startValue: AVAudioSession.sharedInstance().outputVolume
) { startState, handlingState, _ in
guard handlingState.gestureState != .ended else { return }
guard let slider = MPVolumeView()
.subviews
.first(where: { $0 is UISlider }) as? UISlider else { return }
let translation: CGFloat = {
if startState.direction.isHorizontal {
return handlingState.translation.x
} else {
return -handlingState.translation.y
}
}()
let newVolume = clamp(
startState.value + Float(translation / 300),
min: 0,
max: 1
)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
slider.value = newVolume
}
}
}
}