From 5383cf0e904a9c173b8637e141cb82ee273dd61f Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 20 May 2023 22:49:10 +0200 Subject: [PATCH] AVPlayer system controls on iOS --- Backports/ToolbarBackground+Backport.swift | 21 +++ Backports/ToolbarColorScheme+Backport.swift | 12 ++ .../AVPlayerViewController+FullScreen.swift | 11 ++ Model/Player/Backends/AVPlayerBackend.swift | 53 +++++-- Model/Player/PiPDelegate.swift | 17 ++- Model/Player/PlayerModel.swift | 56 ++++++-- Model/Stream.swift | 16 ++- Shared/Defaults.swift | 1 + Shared/Navigation/ContentView.swift | 9 +- Shared/Player/AppleAVPlayerView.swift | 133 +++++++++++++++++- Shared/Player/PlayerBackendView.swift | 35 +++-- Shared/Player/PlayerDragGesture.swift | 19 ++- Shared/Player/VideoPlayerSizeModifier.swift | 33 +++-- Shared/Player/VideoPlayerView.swift | 31 ++-- Shared/Settings/PlayerControlsSettings.swift | 9 ++ Yattee.xcodeproj/project.pbxproj | 18 +++ 16 files changed, 405 insertions(+), 69 deletions(-) create mode 100644 Backports/ToolbarBackground+Backport.swift create mode 100644 Backports/ToolbarColorScheme+Backport.swift create mode 100644 Extensions/AVPlayerViewController+FullScreen.swift diff --git a/Backports/ToolbarBackground+Backport.swift b/Backports/ToolbarBackground+Backport.swift new file mode 100644 index 00000000..f5cbfa07 --- /dev/null +++ b/Backports/ToolbarBackground+Backport.swift @@ -0,0 +1,21 @@ +import SwiftUI + +extension Backport where Content: View { + @ViewBuilder func toolbarBackground(_ color: Color) -> some View { + if #available(iOS 16, *) { + content + .toolbarBackground(color, for: .navigationBar) + } else { + content + } + } + + @ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View { + if #available(iOS 16, *) { + content + .toolbarBackground(visible ? .visible : .hidden, for: .navigationBar) + } else { + content + } + } +} diff --git a/Backports/ToolbarColorScheme+Backport.swift b/Backports/ToolbarColorScheme+Backport.swift new file mode 100644 index 00000000..59eef496 --- /dev/null +++ b/Backports/ToolbarColorScheme+Backport.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension Backport where Content: View { + @ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View { + if #available(iOS 16, *) { + content + .toolbarColorScheme(colorScheme, for: .navigationBar) + } else { + content + } + } +} diff --git a/Extensions/AVPlayerViewController+FullScreen.swift b/Extensions/AVPlayerViewController+FullScreen.swift new file mode 100644 index 00000000..ffbcb00b --- /dev/null +++ b/Extensions/AVPlayerViewController+FullScreen.swift @@ -0,0 +1,11 @@ +import AVKit + +extension AVPlayerViewController { + func enterFullScreen(animated: Bool) { + perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil) + } + + func exitFullScreen(animated: Bool) { + perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil) + } +} diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index 38c236b7..0c88efda 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -1,4 +1,4 @@ -import AVFoundation +import AVKit import Defaults import Foundation import Logging @@ -6,6 +6,7 @@ import MediaPlayer #if !os(macOS) import UIKit #endif +import SwiftUI final class AVPlayerBackend: PlayerBackend { static let assetKeysToLoad = ["tracks", "playable", "duration"] @@ -84,6 +85,10 @@ final class AVPlayerBackend: PlayerBackend { private(set) var playerLayer = AVPlayerLayer() #if os(tvOS) var controller: AppleAVPlayerViewController? + #elseif os(iOS) + var controller = AVPlayerViewController() { didSet { + controller.player = avPlayer + }} #endif var startPictureInPictureOnPlay = false var startPictureInPictureOnSwitch = false @@ -108,6 +113,9 @@ final class AVPlayerBackend: PlayerBackend { addPlayerTimeControlStatusObserver() playerLayer.player = avPlayer + #if os(iOS) + controller.player = avPlayer + #endif } func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? { @@ -469,10 +477,6 @@ final class AVPlayerBackend: PlayerBackend { switch playerItem.status { case .readyToPlay: - if self.model.playingInPictureInPicture { - self.startPictureInPictureOnSwitch = false - self.startPictureInPictureOnPlay = false - } if self.model.activeBackend == .appleAVPlayer, self.isAutoplaying(playerItem) { @@ -487,17 +491,21 @@ final class AVPlayerBackend: PlayerBackend { self.model.play() } } else if self.startPictureInPictureOnPlay { - self.startPictureInPictureOnPlay = false self.model.stream = self.stream self.model.streamSelection = self.stream if self.model.activeBackend != .appleAVPlayer { self.startPictureInPictureOnSwitch = true let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0 - self.seek(to: seconds, seekType: .backendSync) { _ in + self.seek(to: seconds, seekType: .backendSync) { finished in + guard finished else { return } DispatchQueue.main.async { self.model.pause() self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false) + + Delay.by(3) { + self.startPictureInPictureOnPlay = false + } } } } @@ -688,7 +696,6 @@ final class AVPlayerBackend: PlayerBackend { func didChangeTo() { if startPictureInPictureOnSwitch { - startPictureInPictureOnSwitch = false tryStartingPictureInPicture() } else if model.musicMode { startMusicMode() @@ -697,6 +704,10 @@ final class AVPlayerBackend: PlayerBackend { } } + var isStartingPiP: Bool { + startPictureInPictureOnPlay || startPictureInPictureOnSwitch + } + func tryStartingPictureInPicture() { guard let controller = model.pipController else { return } @@ -712,6 +723,32 @@ final class AVPlayerBackend: PlayerBackend { } } } + + Delay.by(5) { + self.startPictureInPictureOnSwitch = false + } + } + + func setPlayerInLayer(_ playerIsPresented: Bool) { + if playerIsPresented { + bindPlayerToLayer() + } else { + removePlayerFromLayer() + } + } + + func removePlayerFromLayer() { + playerLayer.player = nil + #if os(iOS) + controller.player = nil + #endif + } + + func bindPlayerToLayer() { + playerLayer.player = avPlayer + #if os(iOS) + controller.player = avPlayer + #endif } func getTimeUpdates() {} diff --git a/Model/Player/PiPDelegate.swift b/Model/Player/PiPDelegate.swift index 77922c0d..2fa459e2 100644 --- a/Model/Player/PiPDelegate.swift +++ b/Model/Player/PiPDelegate.swift @@ -4,7 +4,7 @@ import Foundation import SwiftUI final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { - var player: PlayerModel! + var player: PlayerModel { .shared } func pictureInPictureController( _: AVPictureInPictureController, @@ -16,19 +16,17 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {} func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) { - guard let player else { return } + player.play() player.playingInPictureInPicture = true player.avPlayerBackend.startPictureInPictureOnPlay = false player.avPlayerBackend.startPictureInPictureOnSwitch = false player.controls.objectWillChange.send() - if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { player.hide() } } + if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } } } func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) { - guard let player else { return } - player.playingInPictureInPicture = false player.controls.objectWillChange.send() } @@ -39,6 +37,8 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { _: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void ) { + let wasPlaying = player.isPlaying + var delay = 0.0 #if os(iOS) if !player.presentingPlayer { @@ -50,7 +50,7 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { #endif if !player.currentItem.isNil, !player.musicMode { - player?.show() + player.show() } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in @@ -58,6 +58,11 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate { self?.player.playingInPictureInPicture = false } + if wasPlaying { + Delay.by(1) { + self?.player.play() + } + } completionHandler(true) } } diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index c681a2b8..6ad806a6 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -49,7 +49,6 @@ final class PlayerModel: ObservableObject { let logger = Logger(label: "stream.yattee.app") - var avPlayerView = AppleAVPlayerView() var playerItem: AVPlayerItem? var mpvPlayerView = MPVPlayerView() @@ -153,6 +152,9 @@ final class PlayerModel: ObservableObject { @Published var playingInPictureInPicture = false var pipController: AVPictureInPictureController? var pipDelegate = PiPDelegate() + #if !os(macOS) + var appleAVPlayerViewControllerDelegate = AppleAVPlayerViewControllerDelegate() + #endif var playerError: Error? { didSet { if let error = playerError { @@ -164,6 +166,7 @@ final class PlayerModel: ObservableObject { @Default(.saveLastPlayed) var saveLastPlayed @Default(.lastPlayed) var lastPlayed @Default(.qualityProfiles) var qualityProfiles + @Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls @Default(.forceAVPlayerForLiveStreams) var forceAVPlayerForLiveStreams @Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer @Default(.closePiPOnNavigation) var closePiPOnNavigation @@ -187,16 +190,17 @@ final class PlayerModel: ObservableObject { mpvBackend.client = mpvController.client #endif - Defaults[.activeBackend] = .mpv playbackMode = Defaults[.playbackMode] guard pipController.isNil else { return } - pipController = .init(playerLayer: avPlayerBackend.playerLayer) - let pipDelegate = PiPDelegate() - pipDelegate.player = self - self.pipDelegate = pipDelegate + pipController = .init(playerLayer: avPlayerBackend.playerLayer) pipController?.delegate = pipDelegate + #if os(iOS) + if #available(iOS 14.2, *) { + pipController?.canStartPictureInPictureAutomaticallyFromInline = true + } + #endif currentRate = playerRate } @@ -475,6 +479,12 @@ final class PlayerModel: ObservableObject { private func handlePresentationChange() { backend.setNeedsDrawing(presentingPlayer) + #if os(iOS) + if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone { + Orientation.lockOrientation(.portrait, andRotateTo: .portrait) + } + #endif + controls.hide() #if !os(macOS) @@ -542,10 +552,15 @@ final class PlayerModel: ObservableObject { self.stream = stream streamSelection = stream + self.upgradeToStream(stream, force: true) + return } - if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) { + if !backend.canPlay(stream) || + (to == .mpv && stream.isHLS) || + (to == .appleAVPlayer && !stream.isHLS) + { guard let preferredStream = streamByQualityProfile else { return } @@ -631,8 +646,8 @@ final class PlayerModel: ObservableObject { if avPlayerBackend.video == video { if activeBackend != .appleAVPlayer { avPlayerBackend.startPictureInPictureOnSwitch = true - changeActiveBackend(from: activeBackend, to: .appleAVPlayer) } + changeActiveBackend(from: activeBackend, to: .appleAVPlayer) } else { avPlayerBackend.startPictureInPictureOnPlay = true playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend) @@ -882,7 +897,7 @@ final class PlayerModel: ObservableObject { #else func handleEnterForeground() { setNeedsDrawing(presentingPlayer) - avPlayerBackend.playerLayer.player = avPlayerBackend.avPlayer + avPlayerBackend.bindPlayerToLayer() guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else { return @@ -896,7 +911,7 @@ final class PlayerModel: ObservableObject { if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode { pause() } else if !playingInPictureInPicture { - avPlayerBackend.playerLayer.player = nil + avPlayerBackend.removePlayerFromLayer() } } #endif @@ -919,6 +934,13 @@ final class PlayerModel: ObservableObject { #if os(tvOS) guard activeBackend == .mpv else { return } #endif + + #if os(iOS) + if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls { + return + } + #endif + guard let video = currentItem?.video else { MPNowPlayingInfoCenter.default().nowPlayingInfo = .none return @@ -986,13 +1008,23 @@ final class PlayerModel: ObservableObject { #if os(iOS) if playingFullScreen { + if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls { + avPlayerBackend.controller.enterFullScreen(animated: true) + } guard rotateToLandscapeOnEnterFullScreen.isRotating else { return } if currentVideoIsLandscape { + let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0 // not sure why but first rotation call is ignore so doing rotate to same orientation first - Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation) - Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotateToLandscapeOnEnterFullScreen.interaceOrientation) + Delay.by(delay) { + let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation + Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation) + Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation) + } } } else { + if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls { + avPlayerBackend.controller.exitFullScreen(animated: true) + } let rotationOrientation = rotateToPortraitOnExitFullScreen ? UIInterfaceOrientation.portrait : nil Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation) } diff --git a/Model/Stream.swift b/Model/Stream.swift index 31b90519..621a17d6 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -176,6 +176,10 @@ class Stream: Equatable, Hashable, Identifiable { localURL != nil } + var isHLS: Bool { + hlsURL != nil + } + var quality: String { guard localURL.isNil else { return "Opened File" } return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")" @@ -229,8 +233,14 @@ class Stream: Equatable, Hashable, Identifiable { } func hash(into hasher: inout Hasher) { - hasher.combine(videoAsset?.url) - hasher.combine(audioAsset?.url) - hasher.combine(hlsURL) + if let url = videoAsset?.url { + hasher.combine(url) + } + if let url = audioAsset?.url { + hasher.combine(url) + } + if let url = hlsURL { + hasher.combine(url) + } } } diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 3453de7c..89092ad5 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -132,6 +132,7 @@ extension Defaults.Keys { static let playerControlsLayout = Key("playerControlsLayout", default: playerControlsLayoutDefault) static let fullScreenPlayerControlsLayout = Key("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault) + static let avPlayerUsesSystemControls = Key("avPlayerUsesSystemControls", default: true) static let horizontalPlayerGestureEnabled = Key("horizontalPlayerGestureEnabled", default: true) static let seekGestureSpeed = Key("seekGestureSpeed", default: 0.5) static let seekGestureSensitivity = Key("seekGestureSensitivity", default: 30.0) diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index e69fcdba..a0ec6bc9 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -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 } } diff --git a/Shared/Player/AppleAVPlayerView.swift b/Shared/Player/AppleAVPlayerView.swift index 0fd72bea..a4e9cef8 100644 --- a/Shared/Player/AppleAVPlayerView.swift +++ b/Shared/Player/AppleAVPlayerView.swift @@ -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 diff --git a/Shared/Player/PlayerBackendView.swift b/Shared/Player/PlayerBackendView.swift index c47df52c..d27c882a 100644 --- a/Shared/Player/PlayerBackendView.swift +++ b/Shared/Player/PlayerBackendView.swift @@ -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 diff --git a/Shared/Player/PlayerDragGesture.swift b/Shared/Player/PlayerDragGesture.swift index ad6c5556..5a5f40a8 100644 --- a/Shared/Player/PlayerDragGesture.swift +++ b/Shared/Player/PlayerDragGesture.swift @@ -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, diff --git a/Shared/Player/VideoPlayerSizeModifier.swift b/Shared/Player/VideoPlayerSizeModifier.swift index cd4d2fbf..398f593e 100644 --- a/Shared/Player/VideoPlayerSizeModifier.swift +++ b/Shared/Player/VideoPlayerSizeModifier.swift @@ -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) } } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 04ef3812..b3dfdef8 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -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 diff --git a/Shared/Settings/PlayerControlsSettings.swift b/Shared/Settings/PlayerControlsSettings.swift index 020844fd..c28f449a 100644 --- a/Shared/Settings/PlayerControlsSettings.swift +++ b/Shared/Settings/PlayerControlsSettings.swift @@ -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) diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index 42b29407..54d1eddf 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -723,6 +723,8 @@ 37B767DD2677C3CA0098BAA8 /* PlayerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */; }; 37B795902771DAE0001CF27B /* OpenURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */; }; 37B795912771DAE0001CF27B /* OpenURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */; }; + 37B7CFE92A19603B001B0564 /* ToolbarBackground+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B7CFE82A19603B001B0564 /* ToolbarBackground+Backport.swift */; }; + 37B7CFEB2A1960EC001B0564 /* ToolbarColorScheme+Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B7CFEA2A1960EC001B0564 /* ToolbarColorScheme+Backport.swift */; }; 37B81AF926D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; }; 37B81AFA26D2C9A700675966 /* VideoPlayerSizeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */; }; 37B81AFC26D2C9C900675966 /* VideoDetailsPaddingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */; }; @@ -885,6 +887,10 @@ 37D9BA0829507F69002586BD /* PlayerControlsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D9BA0529507F69002586BD /* PlayerControlsSettings.swift */; }; 37DCD3112A18E8150059A470 /* OrientationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3102A18E8150059A470 /* OrientationModel.swift */; }; 37DCD3152A18F7630059A470 /* SafeAreaModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3142A18F7630059A470 /* SafeAreaModel.swift */; }; + 37DCD3172A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */; }; + 37DCD3182A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */; }; + 37DCD3192A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */; }; + 37DCD31A2A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */; }; 37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; }; 37DD87C8271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; }; 37DD87C9271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */; }; @@ -1379,6 +1385,8 @@ 37B4E804277D0AB4004BF56A /* Orientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Orientation.swift; sourceTree = ""; }; 37B767DA2677C3CA0098BAA8 /* PlayerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerModel.swift; sourceTree = ""; }; 37B7958F2771DAE0001CF27B /* OpenURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenURLHandler.swift; sourceTree = ""; }; + 37B7CFE82A19603B001B0564 /* ToolbarBackground+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ToolbarBackground+Backport.swift"; sourceTree = ""; }; + 37B7CFEA2A1960EC001B0564 /* ToolbarColorScheme+Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ToolbarColorScheme+Backport.swift"; sourceTree = ""; }; 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSizeModifier.swift; sourceTree = ""; }; 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetailsPaddingModifier.swift; sourceTree = ""; }; 37B81AFE26D2CA3700675966 /* VideoDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDetails.swift; sourceTree = ""; }; @@ -1458,6 +1466,7 @@ 37D9BA0529507F69002586BD /* PlayerControlsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsSettings.swift; sourceTree = ""; }; 37DCD3102A18E8150059A470 /* OrientationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrientationModel.swift; sourceTree = ""; }; 37DCD3142A18F7630059A470 /* SafeAreaModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeAreaModel.swift; sourceTree = ""; }; + 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AVPlayerViewController+FullScreen.swift"; sourceTree = ""; }; 37DD87C6271C9CFE0027CBF9 /* PlayerStreams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStreams.swift; sourceTree = ""; }; 37DD9DA22785BBC900539416 /* NoCommentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCommentsView.swift; sourceTree = ""; }; 37DD9DAF2785D58D00539416 /* RefreshControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RefreshControl.swift; sourceTree = ""; }; @@ -1897,6 +1906,8 @@ 37E80F3F287B472300561799 /* ScrollContentBackground+Backport.swift */, 376E331128AD3B320070E30C /* ScrollDismissesKeyboard+Backport.swift */, 3722AEBF274DAEB8005EA4D6 /* Tint+Backport.swift */, + 37B7CFE82A19603B001B0564 /* ToolbarBackground+Backport.swift */, + 37B7CFEA2A1960EC001B0564 /* ToolbarColorScheme+Backport.swift */, 3727B74927872A920021C15E /* VisualEffectBlur-iOS.swift */, 3727B74727872A500021C15E /* VisualEffectBlur-macOS.swift */, ); @@ -2228,6 +2239,7 @@ isa = PBXGroup; children = ( 379775922689365600DD52A8 /* Array+Next.swift */, + 37DCD3162A191A180059A470 /* AVPlayerViewController+FullScreen.swift */, 376578842685429C00D4EA09 /* CaseIterable+Next.swift */, 37C0697D2725C8D400F7F6CB /* CMTime+DefaultTimescale.swift */, 378AE942274EF00A006A4EE1 /* Color+Background.swift */, @@ -3296,6 +3308,8 @@ 37BE0BCF26A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 377692562946476F0055EC18 /* ChannelPlaylistsCacheModel.swift in Sources */, 3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */, + 37B7CFE92A19603B001B0564 /* ToolbarBackground+Backport.swift in Sources */, + 37DCD3172A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */, 379EF9E029AA585F009FE6C6 /* HideShortsButtons.swift in Sources */, 37F5E8B6291BE9D0006C15F5 /* URLBookmarkModel.swift in Sources */, 37579D5D27864F5F00FD0B98 /* Help.swift in Sources */, @@ -3305,6 +3319,7 @@ 375EC972289F2ABF00751258 /* MultiselectRow.swift in Sources */, 37001563271B1F250049C794 /* AccountsModel.swift in Sources */, 3795593627B08538007FF8F4 /* StreamControl.swift in Sources */, + 37B7CFEB2A1960EC001B0564 /* ToolbarColorScheme+Backport.swift in Sources */, 37A2B346294723850050933E /* CacheModel.swift in Sources */, 37CC3F50270D010D00608308 /* VideoBanner.swift in Sources */, 378E50FB26FE8B9F00F49626 /* Instance.swift in Sources */, @@ -3558,6 +3573,7 @@ 3711404026B206A6005B3555 /* SearchModel.swift in Sources */, 37484C2A26FC83FF00287258 /* AccountForm.swift in Sources */, 37BE0BD026A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, + 37DCD3182A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */, 373CFAEC26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 37D2E0D528B67EFC00F64D52 /* Delay.swift in Sources */, 37977584268922F600DD52A8 /* InvidiousAPI.swift in Sources */, @@ -3615,6 +3631,7 @@ 3774124D27387D2300423605 /* PlaylistsModel.swift in Sources */, 3774124B27387D2300423605 /* ThumbnailsModel.swift in Sources */, 3774125427387D2300423605 /* Store.swift in Sources */, + 37DCD31A2A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */, 3774125027387D2300423605 /* Video.swift in Sources */, 37EF9A79275BEB8E0043B585 /* CommentView.swift in Sources */, 3774125327387D2300423605 /* Country.swift in Sources */, @@ -3813,6 +3830,7 @@ 372D85E0283842EE00FF3C7D /* PlayerLayerView.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37F64FE626FE70A60081B69E /* RedrawOnModifier.swift in Sources */, + 37DCD3192A191A180059A470 /* AVPlayerViewController+FullScreen.swift in Sources */, 37B2631C2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */, 37484C2B26FC83FF00287258 /* AccountForm.swift in Sources */, 37FB2860272225E800A57617 /* ContentItemView.swift in Sources */,