diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 62cda788..0de6af12 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -1,15 +1,18 @@ import AVKit import CoreData +#if os(iOS) + import CoreMotion +#endif import Defaults import Foundation import Logging import MediaPlayer -#if !os(macOS) - import UIKit -#endif import Siesta import SwiftUI import SwiftyJSON +#if !os(macOS) + import UIKit +#endif final class PlayerModel: ObservableObject { static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2] @@ -44,6 +47,12 @@ final class PlayerModel: ObservableObject { @Published var channelWithDetails: Channel? + #if os(iOS) + @Published var motionManager: CMMotionManager! + @Published var lockedOrientation: UIInterfaceOrientation? + @Published var lastOrientation: UIInterfaceOrientation? + #endif + var accounts: AccountsModel var comments: CommentsModel @@ -63,6 +72,7 @@ final class PlayerModel: ObservableObject { private var timeObserverThrottle = Throttle(interval: 2) var playingInPictureInPicture = false + var playingFullscreen = false @Published var presentingErrorDetails = false var playerError: Error? { didSet { @@ -105,11 +115,8 @@ final class PlayerModel: ObservableObject { } func hide() { - guard presentingPlayer else { - return - } - presentingPlayer = false + playerNavigationLinkActive = false } func togglePlayer() { @@ -388,7 +395,9 @@ final class PlayerModel: ObservableObject { self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime) } case .failed: - self?.playerError = error + DispatchQueue.main.async { [weak self] in + self?.playerError = error + } default: return } @@ -808,5 +817,27 @@ final class PlayerModel: ObservableObject { show() closePiP() } + + func enterFullScreen() { + guard !playingFullscreen else { + return + } + + logger.info("entering fullscreen") + + controller?.playerView + .perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil) + } + + func exitFullScreen() { + guard playingFullscreen else { + return + } + + logger.info("exiting fullscreen") + + controller?.playerView + .perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil) + } #endif } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index a38feb9e..53a18818 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -82,6 +82,12 @@ extension Defaults.Keys { #if os(macOS) static let enableBetaChannel = Key("enableBetaChannel", default: false) #endif + + #if os(iOS) + static let honorSystemOrientationLock = Key("honorSystemOrientationLock", default: true) + static let enterFullscreenInLandscape = Key("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone) + static let lockLandscapeWhenEnteringFullscreen = Key("lockLandscapeWhenEnteringFullscreen", default: false) + #endif } enum ResolutionSetting: String, CaseIterable, Defaults.Serializable { diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 09601fbb..4648b73f 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -132,17 +132,32 @@ extension PlayerViewController: AVPlayerViewControllerDelegate { func playerViewController( _: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator - ) {} + ) { + playerModel.playingFullscreen = true + } func playerViewController( _: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator ) { + let wasPlaying = playerModel.isPlaying coordinator.animate(alongsideTransition: nil) { context in + #if os(iOS) + if wasPlaying { + self.playerModel.play() + } + #endif if !context.isCancelled { #if os(iOS) - if self.traitCollection.verticalSizeClass == .compact { - self.dismiss(animated: true) + self.playerModel.lockedOrientation = nil + if Defaults[.enterFullscreenInLandscape] { + Orientation.lockOrientation(.portrait, andRotateTo: .portrait) + } + + self.playerModel.playingFullscreen = false + + if wasPlaying { + self.playerModel.play() } #endif } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index d70bec64..67b1b577 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -1,4 +1,7 @@ import AVKit +#if os(iOS) + import CoreMotion +#endif import Defaults import Siesta import SwiftUI @@ -22,6 +25,16 @@ struct VideoPlayerView: View { @Environment(\.presentationMode) private var presentationMode @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.verticalSizeClass) private var verticalSizeClass + + @Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape + @Default(.honorSystemOrientationLock) private var honorSystemOrientationLock + @Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen + + @State private var motionManager: CMMotionManager! + @State private var orientation = UIInterfaceOrientation.portrait + @State private var lastOrientation: UIInterfaceOrientation? + + private var orientationThrottle = Throttle(interval: 2) #endif @EnvironmentObject private var accounts @@ -38,13 +51,36 @@ struct VideoPlayerView: View { GeometryReader { geometry in HStack(spacing: 0) { content - } - .onAppear { - self.playerSize = geometry.size + .onAppear { + playerSize = geometry.size + + #if os(iOS) + configureOrientationUpdatesBasedOnAccelerometer() + #endif + } } .onChange(of: geometry.size) { size in self.playerSize = size } + #if os(iOS) + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + handleOrientationDidChangeNotification() + } + .onDisappear { + guard !player.playingFullscreen else { + return // swiftlint:disable:this implicit_return + } + + if Defaults[.lockPortraitWhenBrowsing] { + Orientation.lockOrientation(.portrait, andRotateTo: .portrait) + } else { + Orientation.lockOrientation(.allButUpsideDown) + } + + motionManager?.stopAccelerometerUpdates() + motionManager = nil + } + #endif } .navigationBarHidden(true) #endif @@ -192,6 +228,110 @@ struct VideoPlayerView: View { set: { _ in } ) } + + #if os(iOS) + private func configureOrientationUpdatesBasedOnAccelerometer() { + if UIDevice.current.orientation.isLandscape, enterFullscreenInLandscape, !player.playingFullscreen { + DispatchQueue.main.async { + player.enterFullScreen() + } + } + + guard !honorSystemOrientationLock, motionManager.isNil else { + return + } + + motionManager = CMMotionManager() + motionManager.accelerometerUpdateInterval = 0.2 + motionManager.startAccelerometerUpdates(to: OperationQueue()) { data, _ in + guard player.presentingPlayer, !data.isNil else { + return + } + + guard let acceleration = data?.acceleration else { + return + } + + var orientation = UIInterfaceOrientation.unknown + + if acceleration.x >= 0.65 { + orientation = .landscapeLeft + } else if acceleration.x <= -0.65 { + orientation = .landscapeRight + } else if acceleration.y <= -0.65 { + orientation = .portrait + } else if acceleration.y >= 0.65 { + orientation = .portraitUpsideDown + } + + guard lastOrientation != orientation else { + return + } + + lastOrientation = orientation + + if orientation.isLandscape { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + guard enterFullscreenInLandscape else { + return + } + + player.enterFullScreen() + + let orientationLockMask = orientation == .landscapeLeft ? UIInterfaceOrientationMask.landscapeLeft : .landscapeRight + + Orientation.lockOrientation(orientationLockMask, andRotateTo: orientation) + + guard lockLandscapeWhenEnteringFullscreen else { + return + } + + player.lockedOrientation = orientation + } + } else { + guard abs(acceleration.z) <= 0.74, + player.lockedOrientation.isNil, + enterFullscreenInLandscape + else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + player.exitFullScreen() + } + + Orientation.lockOrientation(.portrait) + } + } + } + + private func handleOrientationDidChangeNotification() { + let newOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation + if newOrientation?.isLandscape ?? false, player.presentingPlayer, lockLandscapeWhenEnteringFullscreen, !player.lockedOrientation.isNil { + Orientation.lockOrientation(.landscape, andRotateTo: newOrientation) + return + } + + guard player.presentingPlayer, enterFullscreenInLandscape, honorSystemOrientationLock else { + return + } + + if UIDevice.current.orientation.isLandscape { + DispatchQueue.main.async { + player.lockedOrientation = newOrientation + player.enterFullScreen() + } + } else { + DispatchQueue.main.async { + player.exitFullScreen() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + player.exitFullScreen() + } + } + } + #endif } struct VideoPlayerView_Previews: PreviewProvider { diff --git a/Shared/Settings/PlaybackSettings.swift b/Shared/Settings/PlaybackSettings.swift index f89ceec5..0a03b95a 100644 --- a/Shared/Settings/PlaybackSettings.swift +++ b/Shared/Settings/PlaybackSettings.swift @@ -10,6 +10,11 @@ struct PlaybackSettings: View { @Default(.showKeywords) private var showKeywords @Default(.showChannelSubscribers) private var channelSubscribers @Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer + #if os(iOS) + @Default(.honorSystemOrientationLock) private var honorSystemOrientationLock + @Default(.lockLandscapeWhenEnteringFullscreen) private var lockLandscapeWhenEnteringFullscreen + @Default(.enterFullscreenInLandscape) private var enterFullscreenInLandscape + #endif @Default(.closePiPOnNavigation) private var closePiPOnNavigation @Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer #if !os(macOS) @@ -37,6 +42,13 @@ struct PlaybackSettings: View { showHistoryToggle channelSubscribersToggle pauseOnHidingPlayerToggle + + if idiom == .pad { + enterFullscreenInLandscapeToggle + } + + honorSystemOrientationLockToggle + lockLandscapeWhenEnteringFullscreenToggle } Section(header: SettingsHeader(text: "Picture in Picture")) { @@ -147,6 +159,22 @@ struct PlaybackSettings: View { Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer) } + #if os(iOS) + private var honorSystemOrientationLockToggle: some View { + Toggle("Honor system orientation lock", isOn: $honorSystemOrientationLock) + .disabled(!enterFullscreenInLandscape) + } + + private var enterFullscreenInLandscapeToggle: some View { + Toggle("Enter fullscreen in landscape", isOn: $enterFullscreenInLandscape) + } + + private var lockLandscapeWhenEnteringFullscreenToggle: some View { + Toggle("Lock landscape orientation when entering fullscreen", isOn: $lockLandscapeWhenEnteringFullscreen) + .disabled(!enterFullscreenInLandscape) + } + #endif + private var closePiPOnNavigationToggle: some View { Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation) }