mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
AVPlayer system controls on iOS
This commit is contained in:
@@ -132,6 +132,7 @@ extension Defaults.Keys {
|
||||
|
||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
|
@@ -15,6 +15,8 @@ struct ContentView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
#endif
|
||||
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
@@ -133,6 +135,7 @@ struct ContentView: View {
|
||||
)
|
||||
#endif
|
||||
.alert(isPresented: $navigation.presentingAlert) { navigation.alert }
|
||||
.statusBarHidden(player.playingFullScreen)
|
||||
}
|
||||
|
||||
var navigationStyle: NavigationStyle {
|
||||
@@ -150,9 +153,11 @@ struct ContentView: View {
|
||||
playerView
|
||||
.transition(.asymmetric(insertion: .identity, removal: .opacity))
|
||||
.zIndex(3)
|
||||
} else if player.activeBackend == .appleAVPlayer {
|
||||
} else if player.activeBackend == .appleAVPlayer,
|
||||
avPlayerUsesSystemControls || player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
#if os(iOS)
|
||||
playerView.offset(y: UIScreen.main.bounds.height)
|
||||
AppleAVPlayerLayerView().offset(y: UIScreen.main.bounds.height)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@@ -2,15 +2,119 @@ import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
#if !os(macOS)
|
||||
final class AppleAVPlayerViewControllerDelegate: NSObject, AVPlayerViewControllerDelegate {
|
||||
var player: PlayerModel { .shared }
|
||||
|
||||
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator _: UIViewControllerTransitionCoordinator) {
|
||||
Delay.by(0.5) { [weak self] in
|
||||
self?.player.playingFullScreen = true
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
|
||||
let wasPlaying = player.isPlaying
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
#if os(iOS)
|
||||
if wasPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
#endif
|
||||
if !context.isCancelled {
|
||||
#if os(iOS)
|
||||
self.player.lockedOrientation = nil
|
||||
|
||||
if Defaults[.rotateToPortraitOnExitFullScreen] {
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
|
||||
self.player.playingFullScreen = false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewControllerDidStartPictureInPicture(_: AVPlayerViewController) {
|
||||
player.playingInPictureInPicture = true
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
player.avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
player.controls.objectWillChange.send()
|
||||
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
|
||||
}
|
||||
|
||||
func playerViewControllerDidStopPictureInPicture(_: AVPlayerViewController) {
|
||||
player.playingInPictureInPicture = false
|
||||
player.controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
player.presentingPlayer = true
|
||||
withAnimation(.linear(duration: 0.3)) {
|
||||
self.player.playingInPictureInPicture = false
|
||||
Delay.by(0.5) {
|
||||
completionHandler(true)
|
||||
Delay.by(0.2) {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewController(_: AVPlayerViewController, restoreUserInterfaceForFullScreenExitWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
||||
withAnimation(nil) {
|
||||
player.presentingPlayer = true
|
||||
}
|
||||
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
struct AppleAVPlayerView: UIViewRepresentable {
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
@State private var controller = AVPlayerViewController()
|
||||
|
||||
func makeUIViewController(context _: Context) -> AVPlayerViewController {
|
||||
setupController()
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: AVPlayerViewController, context _: Context) {
|
||||
setupController()
|
||||
}
|
||||
|
||||
func setupController() {
|
||||
controller.delegate = PlayerModel.shared.appleAVPlayerViewControllerDelegate
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
if #available(iOS 14.2, *) {
|
||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
PlayerModel.shared.avPlayerBackend.controller = controller
|
||||
}
|
||||
}
|
||||
|
||||
struct AppleAVPlayerLayerView: UIViewRepresentable {
|
||||
func makeUIView(context _: Context) -> some UIView {
|
||||
PlayerLayerView()
|
||||
PlayerLayerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateUIView(_: UIViewType, context _: Context) {}
|
||||
}
|
||||
#else
|
||||
|
||||
#elseif os(tvOS)
|
||||
struct AppleAVPlayerView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context _: Context) -> AppleAVPlayerViewController {
|
||||
let controller = AppleAVPlayerViewController()
|
||||
@@ -23,4 +127,27 @@ import SwiftUI
|
||||
PlayerModel.shared.rebuildTVMenu()
|
||||
}
|
||||
}
|
||||
#else
|
||||
struct AppleAVPlayerView: NSViewRepresentable {
|
||||
@State private var pictureInPictureDelegate = MacOSPiPDelegate()
|
||||
|
||||
func makeNSView(context _: Context) -> some NSView {
|
||||
let view = AVPlayerView()
|
||||
view.player = PlayerModel.shared.avPlayerBackend.avPlayer
|
||||
view.showsFullScreenToggleButton = true
|
||||
view.allowsPictureInPicturePlayback = true
|
||||
view.pictureInPictureDelegate = pictureInPictureDelegate
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_: NSViewType, context _: Context) {}
|
||||
}
|
||||
|
||||
struct AppleAVPlayerLayerView: NSViewRepresentable {
|
||||
func makeNSView(context _: Context) -> some NSView {
|
||||
PlayerLayerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateNSView(_: NSViewType, context _: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerBackendView: View {
|
||||
@@ -7,6 +8,8 @@ struct PlayerBackendView: View {
|
||||
@ObservedObject private var player = PlayerModel.shared
|
||||
@ObservedObject private var safeAreaModel = SafeAreaModel.shared
|
||||
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
Group {
|
||||
@@ -16,7 +19,20 @@ struct PlayerBackendView: View {
|
||||
case .mpv:
|
||||
player.mpvPlayerView
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
#if os(tvOS)
|
||||
AppleAVPlayerView()
|
||||
#else
|
||||
if avPlayerUsesSystemControls,
|
||||
!player.playingInPictureInPicture,
|
||||
!player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
AppleAVPlayerView()
|
||||
} else if !avPlayerUsesSystemControls ||
|
||||
player.avPlayerBackend.isStartingPiP
|
||||
{
|
||||
AppleAVPlayerLayerView()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.zIndex(0)
|
||||
@@ -31,17 +47,16 @@ struct PlayerBackendView: View {
|
||||
.onChange(of: proxy.size) { _ in player.playerSize = proxy.size }
|
||||
.onChange(of: player.controls.presentingOverlays) { _ in player.playerSize = proxy.size }
|
||||
})
|
||||
#if os(iOS)
|
||||
.padding(.top, player.playingFullScreen && verticalSizeClass == .regular ? 20 : 0)
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
PlayerGestures()
|
||||
PlayerControls()
|
||||
#if os(iOS)
|
||||
.padding(.top, controlsTopPadding)
|
||||
.padding(.bottom, controlsBottomPadding)
|
||||
#endif
|
||||
if player.activeBackend == .mpv || !avPlayerUsesSystemControls {
|
||||
PlayerGestures()
|
||||
PlayerControls()
|
||||
#if os(iOS)
|
||||
.padding(.top, controlsTopPadding)
|
||||
.padding(.bottom, controlsBottomPadding)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
hiddenControlsButton
|
||||
#endif
|
||||
|
@@ -3,7 +3,7 @@ import SwiftUI
|
||||
|
||||
extension VideoPlayerView {
|
||||
var playerDragGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
DragGesture(minimumDistance: 30, coordinateSpace: .global)
|
||||
#if os(iOS)
|
||||
.updating($dragGestureOffset) { value, state, _ in
|
||||
guard isVerticalDrag else { return }
|
||||
@@ -36,7 +36,12 @@ extension VideoPlayerView {
|
||||
}
|
||||
#endif
|
||||
|
||||
if !isVerticalDrag, horizontalPlayerGestureEnabled, abs(horizontalDrag) > seekGestureSensitivity, !isHorizontalDrag {
|
||||
if !isVerticalDrag,
|
||||
horizontalPlayerGestureEnabled,
|
||||
abs(horizontalDrag) > seekGestureSensitivity,
|
||||
!isHorizontalDrag,
|
||||
player.activeBackend == .mpv || !avPlayerUsesSystemControls
|
||||
{
|
||||
isHorizontalDrag = true
|
||||
player.seek.onSeekGestureStart()
|
||||
viewDragOffset = 0
|
||||
@@ -80,6 +85,16 @@ extension VideoPlayerView {
|
||||
player.seek.onSeekGestureEnd()
|
||||
}
|
||||
|
||||
if viewDragOffset > 60,
|
||||
player.playingFullScreen
|
||||
{
|
||||
#if os(iOS)
|
||||
player.lockedOrientation = nil
|
||||
#endif
|
||||
player.exitFullScreen(showControls: false)
|
||||
viewDragOffset = 0
|
||||
return
|
||||
}
|
||||
isVerticalDrag = false
|
||||
|
||||
guard player.presentingPlayer,
|
||||
|
@@ -4,8 +4,8 @@ import SwiftUI
|
||||
struct VideoPlayerSizeModifier: ViewModifier {
|
||||
let geometry: GeometryProxy
|
||||
let aspectRatio: Double?
|
||||
let minimumHeightLeft: Double
|
||||
let fullScreen: Bool
|
||||
var detailsHiddenInFullScreen = true
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -14,26 +14,31 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
init(
|
||||
geometry: GeometryProxy,
|
||||
aspectRatio: Double? = nil,
|
||||
minimumHeightLeft: Double? = nil,
|
||||
fullScreen: Bool = false
|
||||
fullScreen: Bool = false,
|
||||
detailsHiddenInFullScreen: Bool = false
|
||||
) {
|
||||
self.geometry = geometry
|
||||
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.fullScreen = fullScreen
|
||||
self.detailsHiddenInFullScreen = detailsHiddenInFullScreen
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(width: geometry.size.width)
|
||||
.frame(maxWidth: geometry.size.width)
|
||||
.frame(maxHeight: maxHeight)
|
||||
|
||||
#if !os(macOS)
|
||||
.aspectRatio(fullScreen ? nil : usedAspectRatio, contentMode: usedAspectRatioContentMode)
|
||||
.aspectRatio(ratio, contentMode: usedAspectRatioContentMode)
|
||||
#endif
|
||||
}
|
||||
|
||||
var ratio: CGFloat? {
|
||||
fullScreen ? detailsHiddenInFullScreen ? nil : usedAspectRatio : usedAspectRatio
|
||||
}
|
||||
|
||||
var usedAspectRatio: Double {
|
||||
guard let aspectRatio, aspectRatio > 0, aspectRatio < VideoPlayerView.defaultAspectRatio else {
|
||||
guard let aspectRatio, aspectRatio > 0 else {
|
||||
return VideoPlayerView.defaultAspectRatio
|
||||
}
|
||||
|
||||
@@ -50,15 +55,13 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
|
||||
var maxHeight: Double {
|
||||
guard !fullScreen else {
|
||||
return .infinity
|
||||
if detailsHiddenInFullScreen {
|
||||
return geometry.size.height
|
||||
} else {
|
||||
return geometry.size.width / usedAspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let height = verticalSizeClass == .regular ? geometry.size.height - minimumHeightLeft : .infinity
|
||||
#else
|
||||
let height = geometry.size.height - minimumHeightLeft
|
||||
#endif
|
||||
|
||||
return [height, 0].max()!
|
||||
return max(geometry.size.height - VideoPlayerView.defaultMinimumHeightLeft, 0)
|
||||
}
|
||||
}
|
||||
|
@@ -64,6 +64,7 @@ struct VideoPlayerView: View {
|
||||
@Default(.playerSidebar) var playerSidebar
|
||||
@Default(.gestureBackwardSeekDuration) private var gestureBackwardSeekDuration
|
||||
@Default(.gestureForwardSeekDuration) private var gestureForwardSeekDuration
|
||||
@Default(.avPlayerUsesSystemControls) internal var avPlayerUsesSystemControls
|
||||
|
||||
@ObservedObject internal var controlsOverlayModel = ControlOverlaysModel.shared
|
||||
|
||||
@@ -98,12 +99,12 @@ struct VideoPlayerView: View {
|
||||
return GeometryReader { geometry in
|
||||
HStack(spacing: 0) {
|
||||
content
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
.frame(height: playerHeight.isNil ? nil : Double(playerHeight!))
|
||||
.onAppear {
|
||||
playerSize = geometry.size
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
.frame(height: playerHeight.isNil ? nil : Double(playerHeight!))
|
||||
.onChange(of: geometry.size) { _ in
|
||||
self.playerSize = geometry.size
|
||||
}
|
||||
@@ -279,7 +280,8 @@ struct VideoPlayerView: View {
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.aspectRatio,
|
||||
fullScreen: fullScreenPlayer
|
||||
fullScreen: fullScreenPlayer,
|
||||
detailsHiddenInFullScreen: detailsHiddenInFullScreen
|
||||
)
|
||||
)
|
||||
.onHover { hovering in
|
||||
@@ -303,15 +305,12 @@ struct VideoPlayerView: View {
|
||||
|
||||
.background(Color.black)
|
||||
|
||||
if !fullScreenPlayer {
|
||||
if !detailsHiddenInFullScreen {
|
||||
VideoDetails(
|
||||
video: player.videoForDisplay,
|
||||
fullScreen: $fullScreenDetails,
|
||||
sidebarQueue: $sidebarQueue
|
||||
)
|
||||
#if os(iOS)
|
||||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
#endif
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
playerSize: player.playerSize,
|
||||
fullScreen: fullScreenDetails
|
||||
@@ -369,7 +368,7 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if !fullScreenPlayer {
|
||||
if !detailsHiddenInFullScreen {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
List {
|
||||
@@ -404,6 +403,12 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
#if os(iOS)
|
||||
.statusBar(hidden: fullScreenPlayer)
|
||||
.backport
|
||||
.toolbarBackground(colorScheme == .light ? .white : .black)
|
||||
.backport
|
||||
.toolbarBackgroundVisibility(true)
|
||||
.backport
|
||||
.toolbarColorScheme(colorScheme)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
.background(
|
||||
@@ -414,6 +419,16 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
var detailsHiddenInFullScreen: Bool {
|
||||
guard fullScreenPlayer else { return false }
|
||||
|
||||
if player.activeBackend == .mpv {
|
||||
return true
|
||||
}
|
||||
|
||||
return !avPlayerUsesSystemControls || verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
var fullScreenPlayer: Bool {
|
||||
#if os(iOS)
|
||||
player.playingFullScreen || verticalSizeClass == .compact
|
||||
|
@@ -2,6 +2,8 @@ import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerControlsSettings: View {
|
||||
@Default(.avPlayerUsesSystemControls) private var avPlayerUsesSystemControls
|
||||
|
||||
@Default(.systemControlsCommands) private var systemControlsCommands
|
||||
@Default(.playerControlsLayout) private var playerControlsLayout
|
||||
@Default(.fullScreenPlayerControlsLayout) private var fullScreenPlayerControlsLayout
|
||||
@@ -61,6 +63,9 @@ struct PlayerControlsSettings: View {
|
||||
@ViewBuilder var sections: some View {
|
||||
#if !os(tvOS)
|
||||
Section(header: SettingsHeader(text: "Controls".localized()), footer: controlsLayoutFooter) {
|
||||
#if !os(tvOS)
|
||||
avPlayerUsesSystemControlsToggle
|
||||
#endif
|
||||
horizontalPlayerGestureEnabledToggle
|
||||
SettingsHeader(text: "Seek gesture sensitivity".localized(), secondary: true)
|
||||
seekGestureSensitivityPicker
|
||||
@@ -143,6 +148,10 @@ struct PlayerControlsSettings: View {
|
||||
Toggle("Seek with horizontal swipe on video", isOn: $horizontalPlayerGestureEnabled)
|
||||
}
|
||||
|
||||
private var avPlayerUsesSystemControlsToggle: some View {
|
||||
Toggle("Use system controls with AVPlayer", isOn: $avPlayerUsesSystemControls)
|
||||
}
|
||||
|
||||
private var seekGestureSensitivityPicker: some View {
|
||||
Picker("Seek gesture sensitivity", selection: $seekGestureSensitivity) {
|
||||
Text("Highest").tag(1.0)
|
||||
|
Reference in New Issue
Block a user