mirror of
https://github.com/yattee/yattee.git
synced 2025-08-05 02:04:07 +00:00
Bring AVPlayer back to tvOS
This commit is contained in:
@@ -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
|
||||
|
@@ -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) {
|
||||
|
63
Shared/Player/ChapterView.swift
Normal file
63
Shared/Player/ChapterView.swift
Normal 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()
|
||||
}
|
||||
}
|
@@ -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()
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -252,7 +252,9 @@ struct VideoPlayerView: View {
|
||||
ZStack {
|
||||
player.playerBackendView
|
||||
|
||||
tvControls
|
||||
if player.activeBackend == .mpv {
|
||||
tvControls
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
#else
|
||||
|
Reference in New Issue
Block a user