jellyflood/Shared/Objects/MediaPlayerManager/MediaPlayerProxy/MediaPlayerProxy+AVPlayer.s...

241 lines
7.7 KiB
Swift

//
// Swiftfin is subject to the terms of the Mozilla Public
// License, v2.0. If a copy of the MPL was not distributed with this
// file, you can obtain one at https://mozilla.org/MPL/2.0/.
//
// Copyright (c) 2025 Jellyfin & Jellyfin Contributors
//
import AVFoundation
import Combine
import Defaults
import Foundation
import JellyfinAPI
import SwiftUI
// TODO: After NativeVideoPlayer is removed, can move bindings and
// observers to AVPlayerView, like the VLC delegate
// - wouldn't need to have MediaPlayerProxy: MediaPlayerObserver
// TODO: report playback information, see VLCUI.PlaybackInformation (dropped frames, etc.)
// TODO: report buffering state
// TODO: have set seconds with completion handler
@MainActor
class AVMediaPlayerProxy: VideoMediaPlayerProxy {
let isBuffering: PublishedBox<Bool> = .init(initialValue: false)
var isScrubbing: Binding<Bool> = .constant(false)
var scrubbedSeconds: Binding<Duration> = .constant(.zero)
var videoSize: PublishedBox<CGSize> = .init(initialValue: .zero)
let avPlayerLayer: AVPlayerLayer
let player: AVPlayer
// private var rateObserver: NSKeyValueObservation!
private var statusObserver: NSKeyValueObservation!
private var timeControlStatusObserver: NSKeyValueObservation!
private var timeObserver: Any!
private var managerItemObserver: AnyCancellable?
private var managerStateObserver: AnyCancellable?
weak var manager: MediaPlayerManager? {
didSet {
if let manager {
managerItemObserver = manager.$playbackItem
.sink { playbackItem in
if let playbackItem {
self.playNew(item: playbackItem)
}
}
managerStateObserver = manager.$state
.sink { state in
switch state {
case .stopped:
self.playbackStopped()
default: break
}
}
} else {
managerItemObserver?.cancel()
managerStateObserver?.cancel()
}
}
}
init() {
self.player = AVPlayer()
self.avPlayerLayer = AVPlayerLayer(player: player)
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 1, preferredTimescale: 1000),
queue: .main
) { newTime in
let newSeconds = Duration.seconds(newTime.seconds)
if !self.isScrubbing.wrappedValue {
self.scrubbedSeconds.wrappedValue = newSeconds
}
self.manager?.seconds = newSeconds
}
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func stop() {
player.pause()
}
func jumpForward(_ seconds: Duration) {
let currentTime = player.currentTime()
let newTime = currentTime + CMTime(seconds: seconds.seconds, preferredTimescale: 1)
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
func jumpBackward(_ seconds: Duration) {
let currentTime = player.currentTime()
let newTime = max(.zero, currentTime - CMTime(seconds: seconds.seconds, preferredTimescale: 1))
player.seek(to: newTime, toleranceBefore: .zero, toleranceAfter: .zero)
}
func setSeconds(_ seconds: Duration) {
let time = CMTime(seconds: seconds.seconds, preferredTimescale: 1)
player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero)
}
// TODO: complete
func setRate(_ rate: Float) {}
func setAudioStream(_ stream: MediaStream) {}
func setSubtitleStream(_ stream: MediaStream) {}
func setAspectFill(_ aspectFill: Bool) {
avPlayerLayer.videoGravity = aspectFill ? .resizeAspectFill : .resizeAspect
}
var videoPlayerBody: some View {
AVPlayerView()
.environmentObject(self)
}
}
extension AVMediaPlayerProxy {
private func playbackStopped() {
player.pause()
guard let timeObserver else { return }
player.removeTimeObserver(timeObserver)
// rateObserver.invalidate()
statusObserver.invalidate()
timeControlStatusObserver.invalidate()
}
private func playNew(item: MediaPlayerItem) {
let baseItem = item.baseItem
let newAVPlayerItem = AVPlayerItem(url: item.url)
newAVPlayerItem.externalMetadata = item.baseItem.avMetadata
player.replaceCurrentItem(with: newAVPlayerItem)
// TODO: protect against paused
// rateObserver = player.observe(\.rate, options: [.new, .initial]) { _, value in
// DispatchQueue.main.async {
// self.manager?.set(rate: value.newValue ?? 1.0)
// }
// }
timeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new, .initial]) { player, _ in
let timeControlStatus = player.timeControlStatus
DispatchQueue.main.async {
switch timeControlStatus {
case .paused:
self.manager?.setPlaybackRequestStatus(status: .paused)
case .waitingToPlayAtSpecifiedRate: ()
// TODO: buffering
case .playing:
self.manager?.setPlaybackRequestStatus(status: .playing)
@unknown default: ()
}
}
}
// TODO: proper handling of none/unknown states
statusObserver = player.observe(\.currentItem?.status, options: [.new, .initial]) { _, value in
guard let newValue = value.newValue else { return }
switch newValue {
case .failed:
if let error = self.player.error {
DispatchQueue.main.async {
self.manager?.error(JellyfinAPIError("AVPlayer error: \(error.localizedDescription)"))
}
}
case .none, .readyToPlay, .unknown:
let startSeconds = max(.zero, (baseItem.startSeconds ?? .zero) - Duration.seconds(Defaults[.VideoPlayer.resumeOffset]))
self.player.seek(
to: CMTimeMake(
value: startSeconds.components.seconds,
timescale: 1
),
toleranceBefore: .zero,
toleranceAfter: .zero,
completionHandler: { _ in
self.play()
}
)
@unknown default: ()
}
}
}
}
// MARK: - AVPlayerView
extension AVMediaPlayerProxy {
struct AVPlayerView: UIViewRepresentable {
@EnvironmentObject
private var proxy: AVMediaPlayerProxy
@EnvironmentObject
private var scrubbedSeconds: PublishedBox<Duration>
func makeUIView(context: Context) -> UIView {
// proxy.isScrubbing = context.environment.isScrubbing
// proxy.scrubbedSeconds = $scrubbedSeconds.value
UIAVPlayerView(proxy: proxy)
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
private class UIAVPlayerView: UIView {
let proxy: AVMediaPlayerProxy
init(proxy: AVMediaPlayerProxy) {
self.proxy = proxy
super.init(frame: .zero)
layer.addSublayer(proxy.avPlayerLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
proxy.avPlayerLayer.frame = bounds
}
}
}