241 lines
7.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|