Add iOS options for handling landscape fullscreen (fixes #38)

This commit is contained in:
Arkadiusz Fal 2022-01-02 20:43:30 +01:00
parent d6e75295e1
commit 00778b585f
5 changed files with 234 additions and 14 deletions

View File

@ -1,15 +1,18 @@
import AVKit import AVKit
import CoreData import CoreData
#if os(iOS)
import CoreMotion
#endif
import Defaults import Defaults
import Foundation import Foundation
import Logging import Logging
import MediaPlayer import MediaPlayer
#if !os(macOS)
import UIKit
#endif
import Siesta import Siesta
import SwiftUI import SwiftUI
import SwiftyJSON import SwiftyJSON
#if !os(macOS)
import UIKit
#endif
final class PlayerModel: ObservableObject { final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2] 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? @Published var channelWithDetails: Channel?
#if os(iOS)
@Published var motionManager: CMMotionManager!
@Published var lockedOrientation: UIInterfaceOrientation?
@Published var lastOrientation: UIInterfaceOrientation?
#endif
var accounts: AccountsModel var accounts: AccountsModel
var comments: CommentsModel var comments: CommentsModel
@ -63,6 +72,7 @@ final class PlayerModel: ObservableObject {
private var timeObserverThrottle = Throttle(interval: 2) private var timeObserverThrottle = Throttle(interval: 2)
var playingInPictureInPicture = false var playingInPictureInPicture = false
var playingFullscreen = false
@Published var presentingErrorDetails = false @Published var presentingErrorDetails = false
var playerError: Error? { didSet { var playerError: Error? { didSet {
@ -105,11 +115,8 @@ final class PlayerModel: ObservableObject {
} }
func hide() { func hide() {
guard presentingPlayer else {
return
}
presentingPlayer = false presentingPlayer = false
playerNavigationLinkActive = false
} }
func togglePlayer() { func togglePlayer() {
@ -388,7 +395,9 @@ final class PlayerModel: ObservableObject {
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime) self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
} }
case .failed: case .failed:
self?.playerError = error DispatchQueue.main.async { [weak self] in
self?.playerError = error
}
default: default:
return return
} }
@ -808,5 +817,27 @@ final class PlayerModel: ObservableObject {
show() show()
closePiP() 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 #endif
} }

View File

@ -82,6 +82,12 @@ extension Defaults.Keys {
#if os(macOS) #if os(macOS)
static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false) static let enableBetaChannel = Key<Bool>("enableBetaChannel", default: false)
#endif #endif
#if os(iOS)
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
static let lockLandscapeWhenEnteringFullscreen = Key<Bool>("lockLandscapeWhenEnteringFullscreen", default: false)
#endif
} }
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable { enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {

View File

@ -132,17 +132,32 @@ extension PlayerViewController: AVPlayerViewControllerDelegate {
func playerViewController( func playerViewController(
_: AVPlayerViewController, _: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator
) {} ) {
playerModel.playingFullscreen = true
}
func playerViewController( func playerViewController(
_: AVPlayerViewController, _: AVPlayerViewController,
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
) { ) {
let wasPlaying = playerModel.isPlaying
coordinator.animate(alongsideTransition: nil) { context in coordinator.animate(alongsideTransition: nil) { context in
#if os(iOS)
if wasPlaying {
self.playerModel.play()
}
#endif
if !context.isCancelled { if !context.isCancelled {
#if os(iOS) #if os(iOS)
if self.traitCollection.verticalSizeClass == .compact { self.playerModel.lockedOrientation = nil
self.dismiss(animated: true) if Defaults[.enterFullscreenInLandscape] {
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
}
self.playerModel.playingFullscreen = false
if wasPlaying {
self.playerModel.play()
} }
#endif #endif
} }

View File

@ -1,4 +1,7 @@
import AVKit import AVKit
#if os(iOS)
import CoreMotion
#endif
import Defaults import Defaults
import Siesta import Siesta
import SwiftUI import SwiftUI
@ -22,6 +25,16 @@ struct VideoPlayerView: View {
@Environment(\.presentationMode) private var presentationMode @Environment(\.presentationMode) private var presentationMode
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass @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 #endif
@EnvironmentObject<AccountsModel> private var accounts @EnvironmentObject<AccountsModel> private var accounts
@ -38,13 +51,36 @@ struct VideoPlayerView: View {
GeometryReader { geometry in GeometryReader { geometry in
HStack(spacing: 0) { HStack(spacing: 0) {
content content
} .onAppear {
.onAppear { playerSize = geometry.size
self.playerSize = geometry.size
#if os(iOS)
configureOrientationUpdatesBasedOnAccelerometer()
#endif
}
} }
.onChange(of: geometry.size) { size in .onChange(of: geometry.size) { size in
self.playerSize = size 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) .navigationBarHidden(true)
#endif #endif
@ -192,6 +228,110 @@ struct VideoPlayerView: View {
set: { _ in } 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 { struct VideoPlayerView_Previews: PreviewProvider {

View File

@ -10,6 +10,11 @@ struct PlaybackSettings: View {
@Default(.showKeywords) private var showKeywords @Default(.showKeywords) private var showKeywords
@Default(.showChannelSubscribers) private var channelSubscribers @Default(.showChannelSubscribers) private var channelSubscribers
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer @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(.closePiPOnNavigation) private var closePiPOnNavigation
@Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer @Default(.closePiPOnOpeningPlayer) private var closePiPOnOpeningPlayer
#if !os(macOS) #if !os(macOS)
@ -37,6 +42,13 @@ struct PlaybackSettings: View {
showHistoryToggle showHistoryToggle
channelSubscribersToggle channelSubscribersToggle
pauseOnHidingPlayerToggle pauseOnHidingPlayerToggle
if idiom == .pad {
enterFullscreenInLandscapeToggle
}
honorSystemOrientationLockToggle
lockLandscapeWhenEnteringFullscreenToggle
} }
Section(header: SettingsHeader(text: "Picture in Picture")) { Section(header: SettingsHeader(text: "Picture in Picture")) {
@ -147,6 +159,22 @@ struct PlaybackSettings: View {
Toggle("Pause when player is closed", isOn: $pauseOnHidingPlayer) 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 { private var closePiPOnNavigationToggle: some View {
Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation) Toggle("Close PiP when starting playing other video", isOn: $closePiPOnNavigation)
} }