mirror of
https://github.com/yattee/yattee.git
synced 2025-08-05 18:24:02 +00:00
Hello, mpv! 🎉
This commit is contained in:
@@ -1,35 +1,25 @@
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct Player: UIViewControllerRepresentable {
|
||||
struct AVPlayerView: UIViewControllerRepresentable {
|
||||
@EnvironmentObject<CommentsModel> private var comments
|
||||
@EnvironmentObject<NavigationModel> private var navigation
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<SubscriptionsModel> private var subscriptions
|
||||
|
||||
var controller: PlayerViewController?
|
||||
|
||||
init(controller: PlayerViewController? = nil) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
func makeUIViewController(context _: Context) -> PlayerViewController {
|
||||
if self.controller != nil {
|
||||
return self.controller!
|
||||
}
|
||||
|
||||
let controller = PlayerViewController()
|
||||
func makeUIViewController(context _: Context) -> UIViewController {
|
||||
let controller = AppleAVPlayerViewController()
|
||||
|
||||
controller.commentsModel = comments
|
||||
controller.navigationModel = navigation
|
||||
controller.playerModel = player
|
||||
controller.subscriptionsModel = subscriptions
|
||||
player.controller = controller
|
||||
player.avPlayerBackend.controller = controller
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_: PlayerViewController, context _: Context) {
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {
|
||||
player.rebuildTVMenu()
|
||||
}
|
||||
}
|
207
Shared/Player/AVPlayerViewController.swift
Normal file
207
Shared/Player/AVPlayerViewController.swift
Normal file
@@ -0,0 +1,207 @@
|
||||
import AVKit
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
final class AppleAVPlayerViewController: UIViewController {
|
||||
var playerLoaded = false
|
||||
var commentsModel: CommentsModel!
|
||||
var navigationModel: NavigationModel!
|
||||
var playerModel: PlayerModel!
|
||||
var subscriptionsModel: SubscriptionsModel!
|
||||
var playerView = AVPlayerViewController()
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
#if !os(tvOS)
|
||||
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 os(tvOS)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
#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.delegate = self
|
||||
|
||||
#if os(tvOS)
|
||||
var infoViewControllers = [UIHostingController<AnyView>]()
|
||||
if CommentsModel.enabled {
|
||||
infoViewControllers.append(infoViewController([.comments], title: "Comments"))
|
||||
}
|
||||
|
||||
var queueSections = [NowPlayingView.ViewSection.playingNext]
|
||||
if Defaults[.showHistoryInPlayer] {
|
||||
queueSections.append(.playedPreviously)
|
||||
}
|
||||
|
||||
infoViewControllers.append(contentsOf: [
|
||||
infoViewController([.related], title: "Related"),
|
||||
infoViewController(queueSections, title: "Queue")
|
||||
])
|
||||
|
||||
playerView.customInfoViewControllers = infoViewControllers
|
||||
#else
|
||||
embedViewController()
|
||||
#endif
|
||||
}
|
||||
|
||||
#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)
|
||||
)
|
||||
)
|
||||
|
||||
controller.title = title
|
||||
|
||||
return controller
|
||||
}
|
||||
#else
|
||||
func embedViewController() {
|
||||
playerView.view.frame = view.bounds
|
||||
|
||||
addChild(playerView)
|
||||
view.addSubview(playerView.view)
|
||||
|
||||
playerView.didMove(toParent: self)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension AppleAVPlayerViewController: AVPlayerViewControllerDelegate {
|
||||
func playerViewControllerShouldDismiss(_: AVPlayerViewController) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_: AVPlayerViewController) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func playerViewControllerWillBeginDismissalTransition(_: AVPlayerViewController) {
|
||||
if Defaults[.pauseOnHidingPlayer] {
|
||||
playerModel.pause()
|
||||
}
|
||||
dismiss(animated: false)
|
||||
}
|
||||
|
||||
func playerViewControllerDidEndDismissalTransition(_: AVPlayerViewController) {}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willBeginFullScreenPresentationWithAnimationCoordinator context: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
#if os(iOS)
|
||||
if !context.isCancelled, Defaults[.lockLandscapeWhenEnteringFullscreen] {
|
||||
Orientation.lockOrientation(.landscape, andRotateTo: UIDevice.current.orientation.isLandscape ? nil : .landscapeRight)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
||||
) {
|
||||
let wasPlaying = playerModel.isPlaying
|
||||
coordinator.animate(alongsideTransition: nil) { context in
|
||||
#if os(iOS)
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
if !context.isCancelled {
|
||||
#if os(iOS)
|
||||
self.playerModel.lockedOrientation = nil
|
||||
if Defaults[.enterFullscreenInLandscape] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
self.playerModel.play()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewController(
|
||||
_: AVPlayerViewController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if self.navigationModel.presentingChannel {
|
||||
self.playerModel.playerNavigationLinkActive = true
|
||||
} else {
|
||||
self.playerModel.show()
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
if self.playerModel.playingInPictureInPicture {
|
||||
self.present(self.playerView, animated: false) {
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
#else
|
||||
completionHandler(true)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func playerViewControllerWillStartPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = true
|
||||
playerModel.playerNavigationLinkActive = false
|
||||
}
|
||||
|
||||
func playerViewControllerWillStopPictureInPicture(_: AVPlayerViewController) {
|
||||
playerModel.playingInPictureInPicture = false
|
||||
}
|
||||
}
|
237
Shared/Player/Controls/PlayerControls.swift
Normal file
237
Shared/Player/Controls/PlayerControls.swift
Normal file
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerControls: View {
|
||||
static let animation = Animation.easeInOut(duration: 0)
|
||||
|
||||
private var player: PlayerModel!
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
init(player: PlayerModel) {
|
||||
self.player = player
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Group {
|
||||
statusBar
|
||||
.padding(3)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
|
||||
buttonsBar
|
||||
.padding(.top, 4)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
mediumButtonsBar
|
||||
|
||||
Spacer()
|
||||
|
||||
timeline
|
||||
.offset(y: 10)
|
||||
.zIndex(1)
|
||||
|
||||
bottomBar
|
||||
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: 3))
|
||||
}
|
||||
}
|
||||
.opacity(model.presentingControls ? 1 : 0)
|
||||
}
|
||||
.background(controlsBackground)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
|
||||
var controlsBackground: some View {
|
||||
model.presentingControls ?
|
||||
AnyView(
|
||||
PlayerGestures()
|
||||
.background(Color.black.opacity(0.5))
|
||||
) :
|
||||
AnyView(Color.clear)
|
||||
}
|
||||
|
||||
var timeline: some View {
|
||||
TimelineView(duration: durationBinding, current: currentTimeBinding, cornerRadius: 0)
|
||||
}
|
||||
|
||||
var durationBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.duration.seconds },
|
||||
set: { value in model.duration = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
}
|
||||
|
||||
var currentTimeBinding: Binding<Double> {
|
||||
Binding<Double>(
|
||||
get: { model.currentTime.seconds },
|
||||
set: { value in model.currentTime = .secondsInDefaultTimescale(value) }
|
||||
)
|
||||
}
|
||||
|
||||
var statusBar: some View {
|
||||
HStack(spacing: 4) {
|
||||
hidePlayerButton
|
||||
Text(playbackStatus)
|
||||
Spacer()
|
||||
ToggleBackendButton()
|
||||
Text("•")
|
||||
StreamControl()
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.padding(.trailing, 4)
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
|
||||
private var hidePlayerButton: some View {
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
private var playbackStatus: String {
|
||||
if player.live {
|
||||
return "LIVE"
|
||||
}
|
||||
|
||||
guard !player.isLoadingVideo else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
let videoLengthAtRate = (player.currentVideo?.length ?? 0) / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - (player.time?.seconds ?? 0)
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
var buttonsBar: some View {
|
||||
HStack {
|
||||
fullscreenButton
|
||||
Spacer()
|
||||
button("Music Mode", systemImage: "music.note")
|
||||
}
|
||||
}
|
||||
|
||||
var fullscreenButton: some View {
|
||||
button(
|
||||
"Fullscreen",
|
||||
systemImage: fullScreenLayout ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
||||
) {
|
||||
model.toggleFullscreen(fullScreenLayout)
|
||||
}
|
||||
.keyboardShortcut(fullScreenLayout ? .cancelAction : .defaultAction)
|
||||
}
|
||||
|
||||
var mediumButtonsBar: some View {
|
||||
HStack {
|
||||
button("Seek Backward", systemImage: "gobackward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
.keyboardShortcut("k")
|
||||
|
||||
Spacer()
|
||||
|
||||
button(
|
||||
model.isPlaying ? "Pause" : "Play",
|
||||
systemImage: model.isPlaying ? "pause.fill" : "play.fill",
|
||||
size: 50,
|
||||
cornerRadius: 10
|
||||
) {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
.keyboardShortcut("p")
|
||||
.disabled(model.isLoadingVideo)
|
||||
|
||||
Spacer()
|
||||
|
||||
button("Seek Forward", systemImage: "goforward.10", size: 50, cornerRadius: 10) {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
.keyboardShortcut("l")
|
||||
}
|
||||
.font(.system(size: 30))
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
var bottomBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Text(model.playbackTime)
|
||||
}
|
||||
.font(.system(size: 15))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 3)
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
func button(
|
||||
_ label: String,
|
||||
systemImage: String = "arrow.up.left.and.arrow.down.right",
|
||||
size: Double = 30,
|
||||
cornerRadius: Double = 3,
|
||||
action: @escaping () -> Void = {}
|
||||
) -> some View {
|
||||
Button {
|
||||
action()
|
||||
model.resetTimer()
|
||||
} label: {
|
||||
Label(label, systemImage: systemImage)
|
||||
.labelStyle(.iconOnly)
|
||||
.padding()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: size, height: size)
|
||||
#if os(macOS)
|
||||
.background(VisualEffectBlur(material: .hudWindow))
|
||||
#elseif os(iOS)
|
||||
.background(VisualEffectBlur(blurStyle: .systemThinMaterial))
|
||||
#endif
|
||||
.mask(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
model.playingFullscreen || verticalSizeClass == .compact
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerControls_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerControls(player: PlayerModel())
|
||||
}
|
||||
}
|
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
23
Shared/Player/Controls/ToggleBackendButton.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToggleBackendButton: View {
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
player.saveTime {
|
||||
player.changeActiveBackend(from: player.activeBackend, to: player.activeBackend.next())
|
||||
controls.resetTimer()
|
||||
}
|
||||
} label: {
|
||||
Text(player.activeBackend.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleBackendButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ToggleBackendButton()
|
||||
}
|
||||
}
|
63
Shared/Player/MPV/MPVOGLView.swift
Normal file
63
Shared/Player/MPV/MPVOGLView.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import GLKit
|
||||
import OpenGLES
|
||||
|
||||
final class MPVOGLView: GLKView {
|
||||
private var defaultFBO: GLint?
|
||||
|
||||
var mpvGL: UnsafeMutableRawPointer?
|
||||
var needsDrawing = true
|
||||
|
||||
override init(frame: CGRect) {
|
||||
guard let context = EAGLContext(api: .openGLES2) else {
|
||||
print("Failed to initialize OpenGLES 2.0 context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
super.init(frame: frame, context: context)
|
||||
contentMode = .redraw
|
||||
|
||||
EAGLContext.setCurrent(context)
|
||||
|
||||
drawableColorFormat = .RGBA8888
|
||||
drawableDepthFormat = .formatNone
|
||||
drawableStencilFormat = .formatNone
|
||||
|
||||
defaultFBO = -1
|
||||
isOpaque = false
|
||||
|
||||
fillBlack()
|
||||
}
|
||||
|
||||
func fillBlack() {
|
||||
glClearColor(0, 0, 0, 0)
|
||||
glClear(UInt32(GL_COLOR_BUFFER_BIT))
|
||||
}
|
||||
|
||||
override func draw(_: CGRect) {
|
||||
glGetIntegerv(UInt32(GL_FRAMEBUFFER_BINDING), &defaultFBO!)
|
||||
|
||||
if mpvGL != nil {
|
||||
var data = mpv_opengl_fbo(
|
||||
fbo: Int32(defaultFBO!),
|
||||
w: Int32(bounds.size.width) * Int32(contentScaleFactor),
|
||||
h: Int32(bounds.size.height) * Int32(contentScaleFactor),
|
||||
internal_format: 0
|
||||
)
|
||||
var flip: CInt = 1
|
||||
withUnsafeMutablePointer(to: &flip) { flip in
|
||||
withUnsafeMutablePointer(to: &data) { data in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_FBO, data: data),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_FLIP_Y, data: flip),
|
||||
mpv_render_param()
|
||||
]
|
||||
mpv_render_context_render(OpaquePointer(mpvGL), ¶ms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
}
|
||||
}
|
25
Shared/Player/MPV/MPVViewController.swift
Normal file
25
Shared/Player/MPV/MPVViewController.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import UIKit
|
||||
|
||||
final class MPVViewController: UIViewController {
|
||||
var client: MPVClient!
|
||||
var glView: MPVOGLView!
|
||||
|
||||
init() {
|
||||
client = MPVClient()
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.loadView()
|
||||
|
||||
glView = client.create(frame: view.frame)
|
||||
|
||||
view.addSubview(glView)
|
||||
|
||||
super.viewDidLoad()
|
||||
}
|
||||
}
|
@@ -1,231 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackBar: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.inNavigationView) private var inNavigationView
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
#if !os(macOS)
|
||||
closeButton
|
||||
#endif
|
||||
|
||||
if player.currentItem != nil {
|
||||
HStack {
|
||||
Text(playbackStatus)
|
||||
Text("•")
|
||||
rateMenu
|
||||
}
|
||||
.font(.caption2)
|
||||
#if os(macOS)
|
||||
.padding(.leading, 4)
|
||||
#endif
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if !player.lastSkipped.isNil {
|
||||
restoreLastSkippedSegmentButton
|
||||
}
|
||||
if player.live {
|
||||
Image(systemName: "dot.radiowaves.left.and.right")
|
||||
} else if player.isLoadingAvailableStreams || player.isLoadingStream {
|
||||
Image(systemName: "bolt.horizontal.fill")
|
||||
} else if !player.playerError.isNil {
|
||||
Button {
|
||||
player.presentingErrorDetails = true
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
streamControl
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
.frame(alignment: .trailing)
|
||||
.onChange(of: player.streamSelection) { selection in
|
||||
guard !selection.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
player.upgradeToStream(selection!)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(maxWidth: 180)
|
||||
#endif
|
||||
}
|
||||
.transaction { t in t.animation = .none }
|
||||
.font(.caption2)
|
||||
} else {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.foregroundColor(colorScheme == .dark ? .gray : .black)
|
||||
.alert(isPresented: $player.presentingErrorDetails) {
|
||||
Alert(
|
||||
title: Text("Error"),
|
||||
message: Text(player.playerError?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 20)
|
||||
.padding(4)
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button {
|
||||
player.hide()
|
||||
} label: {
|
||||
Label(
|
||||
"Close",
|
||||
systemImage: inNavigationView ? "chevron.backward.circle.fill" : "chevron.down.circle.fill"
|
||||
)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.accessibilityLabel(Text("Close"))
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundColor(.gray)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
|
||||
private var playbackStatus: String {
|
||||
if player.live {
|
||||
return "LIVE"
|
||||
}
|
||||
|
||||
guard !player.isLoadingVideo else {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
guard let video = player.currentVideo,
|
||||
let time = player.time
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let videoLengthAtRate = video.length / Double(player.currentRate)
|
||||
let remainingSeconds = videoLengthAtRate - time.seconds
|
||||
|
||||
if remainingSeconds < 60 {
|
||||
return "less than a minute"
|
||||
}
|
||||
|
||||
let timeFinishAt = Date().addingTimeInterval(remainingSeconds)
|
||||
|
||||
return "ends at \(formattedTimeFinishAt(timeFinishAt))"
|
||||
}
|
||||
|
||||
private func formattedTimeFinishAt(_ date: Date) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
|
||||
dateFormatter.dateStyle = .none
|
||||
dateFormatter.timeStyle = .short
|
||||
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private var rateMenu: some View {
|
||||
#if os(macOS)
|
||||
ratePicker
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 70)
|
||||
#else
|
||||
Menu {
|
||||
ratePicker
|
||||
} label: {
|
||||
Text(player.rateLabel(player.currentRate))
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
private var ratePicker: some View {
|
||||
Picker("", selection: $player.currentRate) {
|
||||
ForEach(PlayerModel.availableRates, id: \.self) { rate in
|
||||
Text(player.rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var restoreLastSkippedSegmentButton: some View {
|
||||
HStack(spacing: 4) {
|
||||
Button {
|
||||
player.restoreLastSkippedSegment()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.uturn.left.circle")
|
||||
Text(player.lastSkipped!.title())
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("•")
|
||||
}
|
||||
}
|
||||
|
||||
private var streamControl: some View {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.quality).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
Menu {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(player.streamSelection?.quality ?? "")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||
let streams = player.availableStreamsSorted.filter { $0.instance == instance }
|
||||
|
||||
return Dictionary(grouping: streams, by: \.kind!)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlaybackBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlaybackBar()
|
||||
.injectFixtureEnvironmentObjects()
|
||||
}
|
||||
}
|
||||
|
55
Shared/Player/PlayerGestures.swift
Normal file
55
Shared/Player/PlayerGestures.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerGestures: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var model
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(-10))
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.togglePlay()
|
||||
}
|
||||
)
|
||||
|
||||
gestureRectangle
|
||||
.tapRecognizer(
|
||||
tapSensitivity: 0.2,
|
||||
singleTapAction: {
|
||||
model.toggle()
|
||||
},
|
||||
doubleTapAction: {
|
||||
player.backend.seek(relative: .secondsInDefaultTimescale(10))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var gestureRectangle: some View {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerGestures_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PlayerGestures()
|
||||
}
|
||||
}
|
79
Shared/Player/StreamControl.swift
Normal file
79
Shared/Player/StreamControl.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StreamControl: View {
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
|
||||
Section(header: Text(instance.longDescription)) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.quality).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
|
||||
#else
|
||||
Menu {
|
||||
ForEach(InstancesModel.all) { instance in
|
||||
let instanceStreams = availableStreamsForInstance(instance)
|
||||
if !instanceStreams.values.isEmpty {
|
||||
let kinds = Array(instanceStreams.keys).sorted { $0 < $1 }
|
||||
Picker("", selection: $player.streamSelection) {
|
||||
ForEach(kinds, id: \.self) { key in
|
||||
ForEach(instanceStreams[key] ?? []) { stream in
|
||||
Text(stream.description).tag(Stream?.some(stream))
|
||||
}
|
||||
|
||||
if kinds.count > 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(player.streamSelection?.quality ?? "no playable streams")
|
||||
}
|
||||
.disabled(player.isLoadingAvailableStreams)
|
||||
#endif
|
||||
}
|
||||
|
||||
.transaction { t in t.animation = .none }
|
||||
.onChange(of: player.streamSelection) { selection in
|
||||
guard !selection.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
player.upgradeToStream(selection!)
|
||||
}
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
|
||||
private func availableStreamsForInstance(_ instance: Instance) -> [Stream.Kind: [Stream]] {
|
||||
let streams = player.availableStreamsSorted.filter { $0.instance == instance }.filter { player.backend.canPlay($0) }
|
||||
|
||||
return Dictionary(grouping: streams, by: \.kind!)
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamControl_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
StreamControl()
|
||||
}
|
||||
}
|
48
Shared/Player/TapRecognizerViewModifier.swift
Normal file
48
Shared/Player/TapRecognizerViewModifier.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TapRecognizerViewModifier: ViewModifier {
|
||||
@State private var singleTapIsTaped: Bool = .init()
|
||||
|
||||
var tapSensitivity: Double
|
||||
var singleTapAction: () -> Void
|
||||
var doubleTapAction: () -> Void
|
||||
|
||||
init(tapSensitivity: Double, singleTapAction: @escaping () -> Void, doubleTapAction: @escaping () -> Void) {
|
||||
self.tapSensitivity = tapSensitivity
|
||||
self.singleTapAction = singleTapAction
|
||||
self.doubleTapAction = doubleTapAction
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.gesture(simultaneouslyGesture)
|
||||
}
|
||||
|
||||
private var singleTapGesture: some Gesture {
|
||||
TapGesture(count: 1).onEnded {
|
||||
singleTapIsTaped = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + tapSensitivity) {
|
||||
if singleTapIsTaped {
|
||||
singleTapAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var doubleTapGesture: some Gesture {
|
||||
TapGesture(count: 2).onEnded {
|
||||
singleTapIsTaped = false
|
||||
doubleTapAction()
|
||||
}
|
||||
}
|
||||
|
||||
private var simultaneouslyGesture: some Gesture {
|
||||
singleTapGesture.simultaneously(with: doubleTapGesture)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func tapRecognizer(tapSensitivity: Double, singleTapAction: @escaping () -> Void, doubleTapAction: @escaping () -> Void) -> some View {
|
||||
modifier(TapRecognizerViewModifier(tapSensitivity: tapSensitivity, singleTapAction: singleTapAction, doubleTapAction: doubleTapAction))
|
||||
}
|
||||
}
|
193
Shared/Player/TimelineView.swift
Normal file
193
Shared/Player/TimelineView.swift
Normal file
@@ -0,0 +1,193 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineView: View {
|
||||
@Binding private var duration: Double
|
||||
@Binding private var current: Double
|
||||
|
||||
@State private var size = CGSize.zero
|
||||
@State private var dragging = false
|
||||
@State private var dragOffset: Double = 0
|
||||
@State private var draggedFrom: Double = 0
|
||||
|
||||
private var start: Double = 0.0
|
||||
private var height = 10.0
|
||||
|
||||
var cornerRadius: Double
|
||||
var thumbTooltipWidth: Double = 100
|
||||
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
@EnvironmentObject<PlayerControlsModel> private var controls
|
||||
|
||||
init(duration: Binding<Double>, current: Binding<Double>, cornerRadius: Double = 10.0) {
|
||||
_duration = duration
|
||||
_current = current
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxHeight: height)
|
||||
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(
|
||||
Color.green
|
||||
)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: current * oneUnitWidth)
|
||||
|
||||
segmentsLayers
|
||||
|
||||
Circle()
|
||||
.strokeBorder(.gray, lineWidth: 1)
|
||||
.background(Circle().fill(dragging ? .gray : .white))
|
||||
.offset(x: thumbOffset)
|
||||
.foregroundColor(.red.opacity(0.6))
|
||||
|
||||
.frame(maxHeight: height * 2)
|
||||
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
if !dragging {
|
||||
controls.removeTimer()
|
||||
draggedFrom = current
|
||||
}
|
||||
|
||||
dragging = true
|
||||
|
||||
let drag = value.translation.width
|
||||
let change = (drag / size.width) * units
|
||||
let changedCurrent = current + change
|
||||
|
||||
guard changedCurrent >= start, changedCurrent <= duration else {
|
||||
return
|
||||
}
|
||||
withAnimation(Animation.linear(duration: 0.2)) {
|
||||
dragOffset = drag
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
current = projectedValue
|
||||
|
||||
player.backend.seek(to: projectedValue)
|
||||
|
||||
dragging = false
|
||||
dragOffset = 0.0
|
||||
draggedFrom = 0.0
|
||||
controls.resetTimer()
|
||||
}
|
||||
)
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.frame(maxWidth: thumbTooltipWidth, maxHeight: 30)
|
||||
|
||||
Text(projectedValue.formattedAsPlaybackTime() ?? "--:--")
|
||||
.foregroundColor(.black)
|
||||
}
|
||||
.animation(.linear(duration: 0.1))
|
||||
.opacity(dragging ? 1 : 0)
|
||||
.offset(x: thumbTooltipOffset, y: -(height * 2) - 7)
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
self.size = proxy.size
|
||||
}
|
||||
.onChange(of: proxy.size) { size in
|
||||
self.size = size
|
||||
}
|
||||
})
|
||||
.gesture(DragGesture(minimumDistance: 0).onEnded { value in
|
||||
let target = (value.location.x / size.width) * units
|
||||
current = target
|
||||
player.backend.seek(to: target)
|
||||
})
|
||||
}
|
||||
|
||||
var projectedValue: Double {
|
||||
let change = (dragOffset / size.width) * units
|
||||
let projected = draggedFrom + change
|
||||
return projected.isFinite ? projected : start
|
||||
}
|
||||
|
||||
var thumbOffset: Double {
|
||||
let offset = dragging ? (draggedThumbHorizontalOffset + dragOffset) : thumbHorizontalOffset
|
||||
return offset.isFinite ? offset : thumbLeadingOffset
|
||||
}
|
||||
|
||||
var thumbTooltipOffset: Double {
|
||||
let offset = (dragging ? ((current * oneUnitWidth) + dragOffset) : (current * oneUnitWidth)) - (thumbTooltipWidth / 2)
|
||||
|
||||
return offset.clamped(to: minThumbTooltipOffset ... maxThumbTooltipOffset)
|
||||
}
|
||||
|
||||
var minThumbTooltipOffset: Double = -10
|
||||
|
||||
var maxThumbTooltipOffset: Double {
|
||||
max(minThumbTooltipOffset, (units * oneUnitWidth) - thumbTooltipWidth + 10)
|
||||
}
|
||||
|
||||
var segmentsLayers: some View {
|
||||
ForEach(player.sponsorBlock.segments, id: \.uuid) { segment in
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.offset(x: segmentLayerHorizontalOffset(segment))
|
||||
.foregroundColor(.red)
|
||||
.frame(maxHeight: height)
|
||||
.frame(width: segmentLayerWidth(segment))
|
||||
}
|
||||
}
|
||||
|
||||
func segmentLayerHorizontalOffset(_ segment: Segment) -> Double {
|
||||
segment.start * oneUnitWidth
|
||||
}
|
||||
|
||||
func segmentLayerWidth(_ segment: Segment) -> Double {
|
||||
let width = segment.duration * oneUnitWidth
|
||||
return width.isFinite ? width : thumbLeadingOffset
|
||||
}
|
||||
|
||||
var draggedThumbHorizontalOffset: Double {
|
||||
thumbLeadingOffset + (draggedFrom * oneUnitWidth)
|
||||
}
|
||||
|
||||
var thumbHorizontalOffset: Double {
|
||||
thumbLeadingOffset + (current * oneUnitWidth)
|
||||
}
|
||||
|
||||
var thumbLeadingOffset: Double {
|
||||
-(size.width / 2)
|
||||
}
|
||||
|
||||
var oneUnitWidth: Double {
|
||||
let one = size.width / units
|
||||
return one.isFinite ? one : 0
|
||||
}
|
||||
|
||||
var units: Double {
|
||||
duration - start
|
||||
}
|
||||
|
||||
func setCurrent(_ current: Double) {
|
||||
withAnimation {
|
||||
self.current = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 40) {
|
||||
TimelineView(duration: .constant(100), current: .constant(0))
|
||||
TimelineView(duration: .constant(100), current: .constant(1))
|
||||
TimelineView(duration: .constant(100), current: .constant(30))
|
||||
TimelineView(duration: .constant(100), current: .constant(50))
|
||||
TimelineView(duration: .constant(100), current: .constant(66))
|
||||
TimelineView(duration: .constant(100), current: .constant(90))
|
||||
TimelineView(duration: .constant(100), current: .constant(100))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ struct VideoDetailsPaddingModifier: ViewModifier {
|
||||
#if os(macOS)
|
||||
30
|
||||
#else
|
||||
40
|
||||
10
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@@ -5,6 +5,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
let geometry: GeometryProxy
|
||||
let aspectRatio: Double?
|
||||
let minimumHeightLeft: Double
|
||||
let fullScreen: Bool
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@@ -13,18 +14,19 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
init(
|
||||
geometry: GeometryProxy,
|
||||
aspectRatio: Double? = nil,
|
||||
minimumHeightLeft: Double? = nil
|
||||
minimumHeightLeft: Double? = nil,
|
||||
fullScreen: Bool = false
|
||||
) {
|
||||
self.geometry = geometry
|
||||
self.aspectRatio = aspectRatio ?? VideoPlayerView.defaultAspectRatio
|
||||
self.minimumHeightLeft = minimumHeightLeft ?? VideoPlayerView.defaultMinimumHeightLeft
|
||||
self.fullScreen = fullScreen
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(maxHeight: maxHeight)
|
||||
.aspectRatio(usedAspectRatio, contentMode: usedAspectRatioContentMode)
|
||||
.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
|
||||
.frame(maxHeight: fullScreen ? .infinity : maxHeight)
|
||||
.aspectRatio(usedAspectRatio, contentMode: .fit)
|
||||
}
|
||||
|
||||
var usedAspectRatio: Double {
|
||||
@@ -44,7 +46,7 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
|
||||
var usedAspectRatioContentMode: ContentMode {
|
||||
#if os(iOS)
|
||||
verticalSizeClass == .regular ? .fit : .fill
|
||||
!fullScreen ? .fit : .fill
|
||||
#else
|
||||
.fit
|
||||
#endif
|
||||
@@ -59,14 +61,4 @@ struct VideoPlayerSizeModifier: ViewModifier {
|
||||
|
||||
return [height, 0].max()!
|
||||
}
|
||||
|
||||
var edgesIgnoringSafeArea: Edge.Set {
|
||||
let empty = Edge.Set()
|
||||
|
||||
#if os(iOS)
|
||||
return verticalSizeClass == .compact ? .all : empty
|
||||
#else
|
||||
return empty
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@@ -22,7 +22,6 @@ struct VideoPlayerView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
@@ -36,6 +35,7 @@ struct VideoPlayerView: View {
|
||||
#endif
|
||||
|
||||
@EnvironmentObject<AccountsModel> private var accounts
|
||||
@EnvironmentObject<PlayerControlsModel> private var playerControls
|
||||
@EnvironmentObject<PlayerModel> private var player
|
||||
|
||||
var body: some View {
|
||||
@@ -60,12 +60,15 @@ struct VideoPlayerView: View {
|
||||
.onChange(of: geometry.size) { size in
|
||||
self.playerSize = size
|
||||
}
|
||||
.onChange(of: fullScreenDetails) { value in
|
||||
player.backend.setNeedsDrawing(!value)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
|
||||
handleOrientationDidChangeNotification()
|
||||
}
|
||||
.onDisappear {
|
||||
guard !player.playingFullscreen else {
|
||||
guard !playerControls.playingFullscreen else {
|
||||
return // swiftlint:disable:this implicit_return
|
||||
}
|
||||
|
||||
@@ -80,7 +83,6 @@ struct VideoPlayerView: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -89,79 +91,117 @@ struct VideoPlayerView: View {
|
||||
Group {
|
||||
#if os(tvOS)
|
||||
player.playerView
|
||||
.ignoresSafeArea(.all, edges: .all)
|
||||
#else
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
if !playerControls.playingFullscreen {
|
||||
#if os(macOS)
|
||||
PlaybackBar()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
PlaybackBar()
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
if player.currentItem.isNil {
|
||||
playerPlaceholder(geometry: geometry)
|
||||
} else if player.playingInPictureInPicture {
|
||||
pictureInPicturePlaceholder(geometry: geometry)
|
||||
} else {
|
||||
player.playerView
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio
|
||||
)
|
||||
ZStack(alignment: .top) {
|
||||
switch player.activeBackend {
|
||||
case .mpv:
|
||||
player.mpvPlayerView
|
||||
.overlay(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
player.playerSize = proxy.size
|
||||
player.mpvBackend.client?.setSize(proxy.size.width, proxy.size.height)
|
||||
}
|
||||
.onChange(of: proxy.size) { _ in
|
||||
player.playerSize = proxy.size
|
||||
player.mpvBackend.client?.setSize(proxy.size.width, proxy.size.height)
|
||||
}
|
||||
})
|
||||
case .appleAVPlayer:
|
||||
player.avPlayerView
|
||||
}
|
||||
|
||||
PlayerGestures()
|
||||
|
||||
PlayerControls(player: player)
|
||||
}
|
||||
.modifier(
|
||||
VideoPlayerSizeModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: playerControls.playingFullscreen
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: fullScreenLayout ? .infinity : nil, maxHeight: fullScreenLayout ? .infinity : nil)
|
||||
|
||||
#if os(iOS)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreenDetails = true
|
||||
}
|
||||
},
|
||||
down: { player.hide() }
|
||||
)
|
||||
.onSwipeGesture(
|
||||
up: {
|
||||
withAnimation {
|
||||
fullScreenDetails = true
|
||||
}
|
||||
},
|
||||
down: { player.hide() }
|
||||
)
|
||||
.onHover { hovering in
|
||||
hovering ? playerControls.show() : playerControls.hide()
|
||||
}
|
||||
#endif
|
||||
|
||||
.background(Color.black)
|
||||
.background(Color.black)
|
||||
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
if !playerControls.playingFullscreen {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
if verticalSizeClass == .regular {
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
}
|
||||
|
||||
#else
|
||||
VideoDetails(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.avPlayerBackend.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.modifier(VideoDetailsPaddingModifier(
|
||||
geometry: geometry,
|
||||
aspectRatio: player.controller?.aspectRatio,
|
||||
fullScreen: fullScreenDetails
|
||||
))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||
.background(((colorScheme == .dark || fullScreenLayout) ? Color.black : Color.white).edgesIgnoringSafeArea(.all))
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 650)
|
||||
#endif
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
if !playerControls.playingFullscreen {
|
||||
#if os(iOS)
|
||||
if sidebarQueue {
|
||||
PlayerQueueView(sidebarQueue: .constant(true), fullScreen: $fullScreenDetails)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Defaults[.playerSidebar] != .never {
|
||||
PlayerQueueView(sidebarQueue: sidebarQueueBinding, fullScreen: $fullScreenDetails)
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.all, edges: fullScreenLayout ? .vertical : Edge.Set())
|
||||
.statusBar(hidden: playerControls.playingFullscreen)
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
|
||||
var fullScreenLayout: Bool {
|
||||
playerControls.playingFullscreen || verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
func playerPlaceholder(geometry: GeometryProxy) -> some View {
|
||||
@@ -235,7 +275,7 @@ struct VideoPlayerView: View {
|
||||
private func configureOrientationUpdatesBasedOnAccelerometer() {
|
||||
if UIDevice.current.orientation.isLandscape,
|
||||
enterFullscreenInLandscape,
|
||||
!player.playingFullscreen,
|
||||
!playerControls.playingFullscreen,
|
||||
!player.playingInPictureInPicture
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
|
Reference in New Issue
Block a user