diff --git a/Shared/Generated/Strings.swift b/Shared/Generated/Strings.swift index 6e011548..e022f597 100644 --- a/Shared/Generated/Strings.swift +++ b/Shared/Generated/Strings.swift @@ -240,6 +240,8 @@ internal enum L10n { internal static var playbackSettings: String { return L10n.tr("Localizable", "playbackSettings") } /// Playback Speed internal static var playbackSpeed: String { return L10n.tr("Localizable", "playbackSpeed") } + /// Player Gestures Lock Gesture Enabled + internal static var playerGesturesLockGestureEnabled: String { return L10n.tr("Localizable", "playerGesturesLockGestureEnabled") } /// Play From Beginning internal static var playFromBeginning: String { return L10n.tr("Localizable", "playFromBeginning") } /// Play Next diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index d2300763..763dbaac 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -48,6 +48,8 @@ extension Defaults.Keys { static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) static let systemControlGesturesEnabled = Key("systemControlGesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let playerGesturesLockGestureEnabled = Key("playerGesturesLockGestureEnabled", default: true, + suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpForward = Key("videoPlayerJumpForward", default: .fifteen, suite: SwiftfinStore.Defaults.generalSuite) static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", default: .fifteen, diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index 677767a0..f65d15ed 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -95,6 +95,9 @@ final class VideoPlayerViewModel: ViewModel { @Published var mediaItems: [BaseItemDto.ItemDetail] + @Published + var isHiddenOverlay = false + // MARK: ShouldShowItems let shouldShowPlayPreviousItem: Bool @@ -116,6 +119,7 @@ final class VideoPlayerViewModel: ViewModel { let overlayType: OverlayType let jumpGesturesEnabled: Bool let systemControlGesturesEnabled: Bool + let playerGesturesLockGestureEnabled: Bool let resumeOffset: Bool let streamType: ServerStreamType let container: String @@ -244,6 +248,7 @@ final class VideoPlayerViewModel: ViewModel { self.jumpForwardLength = Defaults[.videoPlayerJumpForward] self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] self.systemControlGesturesEnabled = Defaults[.systemControlGesturesEnabled] + self.playerGesturesLockGestureEnabled = Defaults[.playerGesturesLockGestureEnabled] self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] self.resumeOffset = Defaults[.resumeOffset] diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index e1a0ffae..0ffb94f2 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -40,6 +40,8 @@ struct SettingsView: View { var jumpGesturesEnabled @Default(.systemControlGesturesEnabled) var systemControlGesturesEnabled + @Default(.playerGesturesLockGestureEnabled) + var playerGesturesLockGestureEnabled @Default(.resumeOffset) var resumeOffset @Default(.subtitleSize) @@ -111,6 +113,8 @@ struct SettingsView: View { Toggle(L10n.systemControlGesturesEnabled, isOn: $systemControlGesturesEnabled) + Toggle(L10n.playerGesturesLockGestureEnabled, isOn: $playerGesturesLockGestureEnabled) + Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) Button { diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 5530e798..3ec74867 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -400,13 +400,11 @@ struct VLCPlayerOverlayView: View { .foregroundColor(Color.white) } - var body: some View { + @ViewBuilder + var contents: some View { if viewModel.overlayType == .normal { mainBody .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } .background { Color(uiColor: .black.withAlphaComponent(0.5)) .ignoresSafeArea() @@ -414,11 +412,22 @@ struct VLCPlayerOverlayView: View { } else { mainBody .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didGenerallyTap() - } } } + + var body: some View { + contents + .onLongPressGesture { + guard viewModel.playerGesturesLockGestureEnabled else { return } + viewModel.playerOverlayDelegate?.didGenerallyTap(point: nil) + viewModel.playerOverlayDelegate?.didLongPress() + } + .gesture(DragGesture(minimumDistance: 0) + .onEnded { value in + viewModel.playerOverlayDelegate?.didGenerallyTap(point: value.location) + }) + .opacity(viewModel.isHiddenOverlay ? 0 : 1) + } } struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { diff --git a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift index 4977b3a4..466ca609 100644 --- a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -8,6 +8,7 @@ import Foundation import JellyfinAPI +import UIKit protocol PlayerOverlayDelegate { @@ -19,7 +20,8 @@ protocol PlayerOverlayDelegate { func didSelectForward() func didSelectMain() - func didGenerallyTap() + func didGenerallyTap(point: CGPoint?) + func didLongPress() func didBeginScrubbing() func didEndScrubbing() diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift index 220ccfb6..9deaf472 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift @@ -28,6 +28,7 @@ class VLCPlayerViewController: UIViewController { private var viewModelListeners = Set() private var overlayDismissTimer: Timer? private var isScreenFilled: Bool = false + private var isGesturesLocked = false private var pinchScale: CGFloat = 1 private var currentPlayerTicks: Int64 { @@ -45,10 +46,15 @@ class VLCPlayerViewController: UIViewController { private var panBeganBrightness = CGFloat.zero private var panBeganVolumeValue = Float.zero private var panBeganPoint = CGPoint.zero + private var tapLocationStack = [CGPoint]() + private var isJumping = false + private var jumpingCompletionWork: DispatchWorkItem? + private var isTapWhenJumping = false private lazy var videoContentView = makeVideoContentView() private lazy var mainGestureView = makeMainGestureView() private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() + private lazy var lockedOverlayView = makeGestureLockedOverlayView() private var currentOverlayHostingController: UIHostingController? private var currentChapterOverlayHostingController: UIHostingController? private var currentJumpBackwardOverlayView: UIImageView? @@ -60,23 +66,33 @@ class VLCPlayerViewController: UIViewController { UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), - UIKeyCommand(title: L10n.nextItem, action: #selector(didSelectPlayNextItem), input: UIKeyCommand.inputRightArrow, + UIKeyCommand(title: L10n.nextItem, + action: #selector(didSelectPlayNextItem), + input: UIKeyCommand.inputRightArrow, modifierFlags: .command), - UIKeyCommand(title: L10n.previousItem, action: #selector(didSelectPlayPreviousItem), input: UIKeyCommand.inputLeftArrow, + UIKeyCommand(title: L10n.previousItem, + action: #selector(didSelectPlayPreviousItem), + input: UIKeyCommand.inputLeftArrow, modifierFlags: .command), UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), ] if let previous = viewModel.playbackSpeed.previous { commands.append(.init(title: "\(L10n.playbackSpeed) \(previous.displayTitle)", - action: #selector(didSelectPreviousPlaybackSpeed), input: "[", modifierFlags: .command)) + action: #selector(didSelectPreviousPlaybackSpeed), + input: "[", + modifierFlags: .command)) } if let next = viewModel.playbackSpeed.next { - commands.append(.init(title: "\(L10n.playbackSpeed) \(next.displayTitle)", action: #selector(didSelectNextPlaybackSpeed), - input: "]", modifierFlags: .command)) + commands.append(.init(title: "\(L10n.playbackSpeed) \(next.displayTitle)", + action: #selector(didSelectNextPlaybackSpeed), + input: "]", + modifierFlags: .command)) } if viewModel.playbackSpeed != .one { commands.append(.init(title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", - action: #selector(didSelectNormalPlaybackSpeed), input: "\\", modifierFlags: .command)) + action: #selector(didSelectNormalPlaybackSpeed), + input: "\\", + modifierFlags: .command)) } commands.forEach { $0.wantsPriorityOverSystemBehavior = true } return commands @@ -102,6 +118,7 @@ class VLCPlayerViewController: UIViewController { view.addSubview(videoContentView) view.addSubview(mainGestureView) view.addSubview(systemControlOverlayLabel) + view.addSubview(lockedOverlayView) } private func setupConstraints() { @@ -121,6 +138,12 @@ class VLCPlayerViewController: UIViewController { systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) + NSLayoutConstraint.activate([ + lockedOverlayView.topAnchor.constraint(equalTo: view.topAnchor), + lockedOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + lockedOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor), + lockedOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) } // MARK: viewWillDisappear @@ -148,12 +171,18 @@ class VLCPlayerViewController: UIViewController { refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, + defaultNotificationCenter.addObserver(self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil) + defaultNotificationCenter.addObserver(self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil) + defaultNotificationCenter.addObserver(self, + selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, object: nil) } @objc @@ -205,22 +234,17 @@ class VLCPlayerViewController: UIViewController { let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) - rightSwipeGesture.direction = .right - - let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) - leftSwipeGesture.direction = .left - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:))) + let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) + view.addGestureRecognizer(singleTapGesture) view.addGestureRecognizer(pinchGesture) - if viewModel.jumpGesturesEnabled { - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) + if viewModel.playerGesturesLockGestureEnabled { + view.addGestureRecognizer(longPressGesture) } if viewModel.systemControlGesturesEnabled { @@ -237,24 +261,51 @@ class VLCPlayerViewController: UIViewController { label.alpha = 0 label.translatesAutoresizingMaskIntoConstraints = false label.font = .systemFont(ofSize: 48) + label.layer.zPosition = 1 return label } + // MARK: GestureLockedOverlayView + + private func makeGestureLockedOverlayView() -> UIView { + let backgroundView = UIView() + backgroundView.layer.zPosition = 1 + backgroundView.alpha = 0 + backgroundView.translatesAutoresizingMaskIntoConstraints = false + let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in + self?.isGesturesLocked = false + self?.hideLockedOverlay() + self?.didGenerallyTap() + })) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "lock.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white), + for: .normal) + backgroundView.addSubview(button) + + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), + button.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), + ]) + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + backgroundView.addGestureRecognizer(singleTapGesture) + + return backgroundView + } + @objc - private func didTap() { + private func didTap(_ gestureRecognizer: UITapGestureRecognizer) { + didGenerallyTap(point: gestureRecognizer.location(in: mainGestureView)) + } + + @objc + func didLongPress() { + guard !isGesturesLocked else { return } + isGesturesLocked = true didGenerallyTap() } - @objc - private func didRightSwipe() { - didSelectForward() - } - - @objc - private func didLeftSwipe() { - didSelectBackward() - } - @objc private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { @@ -587,9 +638,10 @@ extension VLCPlayerViewController { guard let overlayHostingController = currentOverlayHostingController else { return } guard overlayHostingController.view.alpha != 1 else { return } + overlayHostingController.view.alpha = 1 - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 + withAnimation(.easeInOut(duration: 0.2)) { [weak self] in + self?.viewModel.isHiddenOverlay = false } } @@ -600,8 +652,16 @@ extension VLCPlayerViewController { guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { + // for gestures UX + view.exchangeSubview(at: view.subviews.firstIndex(of: mainGestureView)!, + withSubviewAt: view.subviews.firstIndex(of: overlayHostingController.view)!) + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { overlayHostingController.view.alpha = 0 + } completion: { [weak self] _ in + guard let self = self else { return } + self.view.exchangeSubview(at: self.view.subviews.firstIndex(of: self.mainGestureView)!, + withSubviewAt: self.view.subviews.firstIndex(of: overlayHostingController.view)!) + self.viewModel.isHiddenOverlay = true } } @@ -616,6 +676,36 @@ extension VLCPlayerViewController { } } +// MARK: Show/Hide Locked Overlay + +extension VLCPlayerViewController { + private func showLockedOverlay() { + guard lockedOverlayView.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + self.lockedOverlayView.alpha = 1 + } + } + + private func hideLockedOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } + + guard lockedOverlayView.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + self.lockedOverlayView.alpha = 0 + } + } + + private func toggleLockedOverlay() { + if lockedOverlayView.alpha < 1 { + showLockedOverlay() + } else { + hideLockedOverlay() + } + } +} + // MARK: Show/Hide System Control extension VLCPlayerViewController { @@ -736,13 +826,17 @@ extension VLCPlayerViewController { extension VLCPlayerViewController { private func restartOverlayDismissTimer(interval: Double = 3) { overlayDismissTimer?.invalidate() - overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), - userInfo: nil, repeats: false) + overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, + target: self, + selector: #selector(dismissTimerFired), + userInfo: nil, + repeats: false) } @objc private func dismissTimerFired() { hideOverlay() + hideLockedOverlay() } private func stopOverlayDismissTimer() { @@ -904,12 +998,65 @@ extension VLCPlayerViewController: PlayerOverlayDelegate { } } - func didGenerallyTap() { - toggleOverlay() + func didGenerallyTap(point: CGPoint? = nil) { + if isGesturesLocked { + toggleLockedOverlay() + } else { + if viewModel.jumpGesturesEnabled, + let point = point + { + let tempStack = tapLocationStack + tapLocationStack.append(point) + + if isSameLocationWithLast(point: point, in: tempStack) { + isTapWhenJumping = false + isJumping = true + tapLocationStack.removeAll() + jumpingCompletionWork?.cancel() + jumpingCompletionWork = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + self.isJumping = false + guard self.isTapWhenJumping else { return } + self.isTapWhenJumping = false + self.toggleOverlay() + }) + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: jumpingCompletionWork!) + + hideOverlay() + if point.x > (mainGestureView.frame.width / 2) { + didSelectForward() + } else { + didSelectBackward() + } + return + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self = self else { return } + guard !self.tapLocationStack.isEmpty else { return } + self.tapLocationStack.removeFirst() + } + } + } + guard !isJumping else { + isTapWhenJumping = true + return + } + + toggleOverlay() + } restartOverlayDismissTimer(interval: 5) } + private func isSameLocationWithLast(point: CGPoint, in stack: [CGPoint]) -> Bool { + guard let last = stack.last else { return false } + if last.x > (mainGestureView.frame.width / 2) { + return point.x > (mainGestureView.frame.width / 2) + } else { + return point.x <= (mainGestureView.frame.width / 2) + } + } + func didBeginScrubbing() { stopOverlayDismissTimer() } diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 36e2d4a0..3f710b26 100644 Binary files a/Translations/en.lproj/Localizable.strings and b/Translations/en.lproj/Localizable.strings differ