// // 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 = .init(initialValue: false) var isScrubbing: Binding = .constant(false) var scrubbedSeconds: Binding = .constant(.zero) var videoSize: PublishedBox = .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 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 } } }