Bring AVPlayer back to tvOS

This commit is contained in:
Arkadiusz Fal
2022-08-20 23:05:40 +02:00
parent 48e616b301
commit ae9b23b9e7
16 changed files with 245 additions and 197 deletions

View File

@@ -2,14 +2,44 @@ import AVKit
import Defaults
import SwiftUI
struct AppleAVPlayerView: UIViewRepresentable {
@EnvironmentObject<PlayerModel> private var player
#if os(iOS)
struct AppleAVPlayerView: UIViewRepresentable {
@EnvironmentObject<PlayerModel> private var player
func makeUIView(context _: Context) -> some UIView {
let playerLayerView = PlayerLayerView(frame: .zero)
playerLayerView.player = player
return playerLayerView
func makeUIView(context _: Context) -> some UIView {
let playerLayerView = PlayerLayerView(frame: .zero)
playerLayerView.player = player
return playerLayerView
}
func updateUIView(_: UIViewType, context _: Context) {}
}
#else
struct AppleAVPlayerView: UIViewControllerRepresentable {
@EnvironmentObject<AccountsModel> private var accounts
@EnvironmentObject<CommentsModel> private var comments
@EnvironmentObject<NavigationModel> private var navigation
@EnvironmentObject<PlayerModel> private var player
@EnvironmentObject<PlaylistsModel> private var playlists
@EnvironmentObject<SubscriptionsModel> private var subscriptions
func updateUIView(_: UIViewType, context _: Context) {}
}
func makeUIViewController(context _: Context) -> AppleAVPlayerViewController {
let controller = AppleAVPlayerViewController()
controller.accountsModel = accounts
controller.commentsModel = comments
controller.navigationModel = navigation
controller.playerModel = player
controller.playlistsModel = playlists
controller.subscriptionsModel = subscriptions
player.avPlayerBackend.controller = controller
return controller
}
func updateUIViewController(_: AppleAVPlayerViewController, context _: Context) {
player.rebuildTVMenu()
}
}
#endif

View File

@@ -4,125 +4,93 @@ import SwiftUI
final class AppleAVPlayerViewController: UIViewController {
var playerLoaded = false
var accountsModel: AccountsModel!
var commentsModel: CommentsModel!
var navigationModel: NavigationModel!
var playerModel: PlayerModel!
var playlistsModel: PlaylistsModel!
var subscriptionsModel: SubscriptionsModel!
var playerView = AVPlayerViewController()
let persistenceController = PersistenceController.shared
#if os(iOS)
var aspectRatio: Double? {
let ratio = Double(playerView.videoBounds.width) / Double(playerView.videoBounds.height)
guard ratio.isFinite else {
return VideoPlayerView.defaultAspectRatio // swiftlint:disable:this implicit_return
}
return [ratio, 1.0].max()!
}
#endif
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadPlayer()
#if os(tvOS)
if !playerView.isBeingPresented, !playerView.isBeingDismissed {
present(playerView, animated: false)
}
#endif
if playerModel.presentingPlayer, !playerView.isBeingPresented, !playerView.isBeingDismissed {
present(playerView, animated: false)
}
}
#if os(tvOS)
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.playerModel.play()
}
if !playerModel.presentingPlayer, !Defaults[.pauseOnHidingPlayer], !playerModel.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.playerModel.play()
}
}
#endif
}
func loadPlayer() {
guard !playerLoaded else {
return
}
playerModel.avPlayerBackend.controller = self
playerView.player = playerModel.avPlayerBackend.avPlayer
playerView.allowsPictureInPicturePlayback = true
playerView.showsPlaybackControls = false
#if os(iOS)
if #available(iOS 14.2, *) {
playerView.canStartPictureInPictureAutomaticallyFromInline = true
}
#endif
playerView.showsPlaybackControls = true
playerView.delegate = self
#if os(tvOS)
var infoViewControllers = [UIHostingController<AnyView>]()
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
var infoViewControllers = [UIHostingController<AnyView>]()
infoViewControllers.append(infoViewController([.chapters], title: "Chapters"))
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
var queueSections = [NowPlayingView.ViewSection.playingNext]
if Defaults[.showHistoryInPlayer] {
queueSections.append(.playedPreviously)
}
var queueSections = [NowPlayingView.ViewSection.playingNext]
if Defaults[.showHistoryInPlayer] {
queueSections.append(.playedPreviously)
}
infoViewControllers.append(contentsOf: [
infoViewController([.related], title: "Related"),
infoViewController(queueSections, title: "Queue")
])
infoViewControllers.append(contentsOf: [
infoViewController([.related], title: "Related"),
infoViewController(queueSections, title: "Queue")
])
playerView.customInfoViewControllers = infoViewControllers
#else
embedViewController()
#endif
playerView.customInfoViewControllers = infoViewControllers
}
#if os(tvOS)
func infoViewController(
_ sections: [NowPlayingView.ViewSection],
title: String
) -> UIHostingController<AnyView> {
let controller = UIHostingController(rootView:
AnyView(
NowPlayingView(sections: sections, inInfoViewController: true)
.frame(maxHeight: 600)
.environmentObject(commentsModel)
.environmentObject(playerModel)
.environmentObject(subscriptionsModel)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
)
func infoViewController(
_ sections: [NowPlayingView.ViewSection],
title: String
) -> UIHostingController<AnyView> {
let controller = UIHostingController(rootView:
AnyView(
NowPlayingView(sections: sections, inInfoViewController: true)
.frame(maxHeight: 600)
.environmentObject(accountsModel)
.environmentObject(commentsModel)
.environmentObject(playerModel)
.environmentObject(playlistsModel)
.environmentObject(subscriptionsModel)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
)
)
controller.title = title
controller.title = title
return controller
}
#else
func embedViewController() {
playerView.view.frame = view.bounds
addChild(playerView)
view.addSubview(playerView.view)
playerView.didMove(toParent: self)
}
#endif
return controller
}
}
extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
func playerViewControllerShouldDismiss(_: AVPlayerViewController) -> Bool {
false
true
}
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
false
true
}
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {

View File

@@ -0,0 +1,63 @@
import Foundation
import SDWebImageSwiftUI
import SwiftUI
struct ChapterView: View {
var chapter: Chapter
@EnvironmentObject<PlayerModel> private var player
var body: some View {
Button {
player.backend.seek(to: chapter.start)
} label: {
HStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.font(.headline)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image)
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
private var thumbnailWidth: Double {
#if os(tvOS)
250
#else
100
#endif
}
}
struct ChapterView_Preview: PreviewProvider {
static var previews: some View {
ChapterView(chapter: .init(title: "Chapter", start: 30))
.injectFixtureEnvironmentObjects()
}
}

View File

@@ -10,12 +10,7 @@ struct ChaptersView: View {
List {
Section(header: Text("Chapters")) {
ForEach(chapters) { chapter in
Button {
player.backend.seek(to: chapter.start)
} label: {
chapterButtonLabel(chapter)
}
.buttonStyle(.plain)
ChapterView(chapter: chapter)
}
}
.listRowBackground(Color.clear)
@@ -33,51 +28,9 @@ struct ChaptersView: View {
NoCommentsView(text: "No chapters information available", systemImage: "xmark.circle.fill")
}
}
@ViewBuilder func chapterButtonLabel(_ chapter: Chapter) -> some View {
HStack(spacing: 12) {
if !chapter.image.isNil {
smallImage(chapter)
}
VStack(alignment: .leading, spacing: 4) {
Text(chapter.title)
.font(.headline)
Text(chapter.start.formattedAsPlaybackTime(allowZero: true) ?? "")
.font(.system(.subheadline).monospacedDigit())
.foregroundColor(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
@ViewBuilder func smallImage(_ chapter: Chapter) -> some View {
WebImage(url: chapter.image)
.resizable()
.placeholder {
ProgressView()
}
.indicator(.activity)
#if os(tvOS)
.frame(width: thumbnailWidth, height: 140)
.mask(RoundedRectangle(cornerRadius: 12))
#else
.frame(width: thumbnailWidth, height: 60)
.mask(RoundedRectangle(cornerRadius: 6))
#endif
}
private var thumbnailWidth: Double {
#if os(tvOS)
250
#else
100
#endif
}
}
struct ChaptersView_Preview: PreviewProvider {
struct ChaptersView_Previews: PreviewProvider {
static var previews: some View {
ChaptersView()
.injectFixtureEnvironmentObjects()

View File

@@ -58,23 +58,15 @@ struct ControlsOverlay: View {
#endif
}
#if os(tvOS)
let streamAndPlayerHeaderText = "Stream"
#else
let streamAndPlayerHeaderText = "Stream & Player"
#endif
Section(header: controlsHeader(streamAndPlayerHeaderText)) {
Section(header: controlsHeader("Stream & Player")) {
qualityButton
#if os(tvOS)
.focused($focusedField, equals: .stream)
#endif
#if !os(tvOS)
HStack {
backendButtons
}
#endif
HStack(spacing: 8) {
backendButtons
}
}
if player.activeBackend == .mpv,
@@ -129,11 +121,13 @@ struct ControlsOverlay: View {
private var backendButtons: some View {
ForEach(PlayerBackendType.allCases, id: \.self) { backend in
backendButton(backend)
#if !os(tvOS)
.frame(height: 40)
#endif
#if os(iOS)
.frame(maxWidth: 115)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(maxWidth: 115)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
}
}
@@ -150,9 +144,6 @@ struct ControlsOverlay: View {
}
#if os(macOS)
.buttonStyle(.bordered)
#elseif os(tvOS)
.modifier(ControlBackgroundModifier())
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
}

View File

@@ -89,14 +89,15 @@ struct PlayerControls: View {
}
.frame(maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
#if os(tvOS)
.onChange(of: model.presentingControls) { newValue in
if newValue { focusedField = .play }
}
.onChange(of: focusedField) { _ in model.resetTimer() }
.onChange(of: model.presentingControls) { newValue in
if newValue { focusedField = .play }
}
.onChange(of: focusedField) { _ in model.resetTimer() }
#else
.background(PlayerGestures())
.background(controlsBackground)
.background(PlayerGestures())
.background(controlsBackground)
#endif
if model.presentingDetailsOverlay {
@@ -176,6 +177,7 @@ struct PlayerControls: View {
}
.retryOnAppear(true)
.indicator(.activity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -273,7 +275,6 @@ struct PlayerControls: View {
private var musicModeButton: some View {
button("Music Mode", systemImage: "music.note", background: false, active: player.musicMode, action: player.toggleMusicMode)
.disabled(player.activeBackend == .appleAVPlayer)
}
private var pipButton: some View {

View File

@@ -60,7 +60,7 @@ struct StreamControl: View {
.frame(maxWidth: 320)
}
.contextMenu {
ForEach(player.availableStreamsSorted) { stream in
ForEach(streams) { stream in
Button(stream.description) { player.streamSelection = stream }
}
@@ -79,10 +79,14 @@ struct StreamControl: View {
}
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
let streams = player.availableStreamsSorted.filter { $0.instance == instance }.filter { player.backend.canPlay($0) }
let streams = streams.filter { $0.instance == instance }.filter { player.backend.canPlay($0) }
return Dictionary(grouping: streams, by: \.kind!)
}
var streams: [Stream] {
player.availableStreamsSorted.filter { player.backend.canPlay($0) }
}
}
struct StreamControl_Previews: PreviewProvider {

View File

@@ -252,7 +252,9 @@ struct VideoPlayerView: View {
ZStack {
player.playerBackendView
tvControls
if player.activeBackend == .mpv {
tvControls
}
}
.ignoresSafeArea()
#else