1
0
mirror of https://github.com/yattee/yattee.git synced 2024-12-14 06:10:32 +05:30
yattee/Model/Player/Backends/MPVBackend.swift
Toni Förster b54044cbc5
HLS: set target bitrate / AVPlayer: higher resolution
HLS: try matching the set resolution. This works okay with AVPlayer. With MPV it is hit and miss, most of the time MPV targets the highest available bitrate, instead of the set bitrate.

AVPlayer now supports higher resolution up to 1080p60.
2024-05-13 07:54:24 +02:00

608 lines
17 KiB
Swift

import AVFAudio
import CoreMedia
import Defaults
import Foundation
import Logging
import MediaPlayer
import MPVKit
import Repeat
import SwiftUI
final class MPVBackend: PlayerBackend {
static var timeUpdateInterval = 0.5
static var networkStateUpdateInterval = 1.0
private var logger = Logger(label: "mpv-backend")
var model: PlayerModel { .shared }
var controls: PlayerControlsModel { .shared }
var playerTime: PlayerTimeModel { .shared }
var networkState: NetworkStateModel { .shared }
var seek: SeekModel { .shared }
var stream: Stream?
var video: Video?
var captions: Captions? { didSet {
guard let captions else {
client?.removeSubs()
return
}
addSubTrack(captions.url)
}}
var currentTime: CMTime?
var loadedVideo = false
var isLoadingVideo = true { didSet {
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.controls.isLoadingVideo = self.isLoadingVideo
self.setNeedsNetworkStateUpdates(true)
self.model.objectWillChange.send()
}
}}
var isPlaying = true { didSet {
networkStateTimer.start()
if isPlaying {
startClientUpdates()
} else {
stopControlsUpdates()
}
updateControlsIsPlaying()
#if os(macOS)
if isPlaying {
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
} else {
ScreenSaverManager.shared.enable()
}
MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
#else
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
}
#endif
}}
var isSeeking = false {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.model.isSeeking = self.isSeeking
}
}
}
var playerItemDuration: CMTime?
#if !os(macOS)
var controller: MPVViewController!
#endif
var client: MPVClient! { didSet { client.backend = self } }
private var clientTimer: Repeater!
private var networkStateTimer: Repeater!
private var onFileLoaded: (() -> Void)?
var controlsUpdates = false
private var timeObserverThrottle = Throttle(interval: 2)
var suggestedPlaybackRates: [Double] {
[0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4]
}
func canPlayAtRate(_ rate: Double) -> Bool {
rate > 0 && rate <= 100
}
var tracks: Int {
client?.tracksCount ?? -1
}
var aspectRatio: Double {
client?.aspectRatio ?? VideoPlayerView.defaultAspectRatio
}
var frameDropCount: Int {
client?.frameDropCount ?? 0
}
var outputFps: Double {
client?.outputFps ?? 0
}
var formattedOutputFps: String {
String(format: "%.2ffps", outputFps)
}
var hwDecoder: String {
client?.hwDecoder ?? "unknown"
}
var bufferingState: Double {
client?.bufferingState ?? 0
}
var cacheDuration: Double {
client?.cacheDuration ?? 0
}
var videoFormat: String {
client?.videoFormat ?? "unknown"
}
var videoCodec: String {
client?.videoCodec ?? "unknown"
}
var currentVo: String {
client?.currentVo ?? "unknown"
}
var videoWidth: Double? {
if let width = client?.width, width != "unknown" {
return Double(width)
}
return nil
}
var videoHeight: Double? {
if let height = client?.height, height != "unknown" {
return Double(height)
}
return nil
}
var audioFormat: String {
client?.audioFormat ?? "unknown"
}
var audioCodec: String {
client?.audioCodec ?? "unknown"
}
var currentAo: String {
client?.currentAo ?? "unknown"
}
var audioChannels: String {
client?.audioChannels ?? "unknown"
}
var audioSampleRate: String {
client?.audioSampleRate ?? "unknown"
}
init() {
// swiftlint:disable shorthand_optional_binding
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else {
return
}
self.getTimeUpdates()
}
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
guard let self = self, self.model.activeBackend == .mpv else {
return
}
self.updateNetworkState()
}
// swiftlint:enable shorthand_optional_binding
}
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
func canPlay(_ stream: Stream) -> Bool {
stream.resolution != .unknown && stream.format != .av1
}
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
#if !os(macOS)
if model.presentingPlayer {
DispatchQueue.main.async {
UIApplication.shared.isIdleTimerDisabled = true
}
}
#endif
var captions: Captions?
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
captions = video.captions.first { $0.code == captionsLanguageCode } ??
video.captions.first { $0.code.contains(captionsLanguageCode) }
}
let updateCurrentStream = {
DispatchQueue.main.async { [weak self] in
self?.stream = stream
self?.video = video
self?.model.stream = stream
self?.captions = captions
}
}
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
DispatchQueue.main.async { [weak self] in
guard let self else {
return
}
self.startClientUpdates()
if !preservingTime,
!upgrading,
let segment = self.model.sponsorBlock.segments.first,
self.model.lastSkipped.isNil
{
self.seek(to: segment.endTime, seekType: .segmentSkip(segment.category)) { finished in
guard finished else {
return
}
self.model.lastSkipped = segment
self.play()
self.model.handleOnPlayStream(stream)
}
} else {
self.play()
self.model.handleOnPlayStream(stream)
}
}
}
let replaceItem: (CMTime?) -> Void = { [weak self] time in
guard let self else {
return
}
self.stop()
DispatchQueue.main.async { [weak self] in
guard let self, let client = self.client else {
return
}
if let url = stream.singleAssetURL {
self.onFileLoaded = {
updateCurrentStream()
startPlaying()
}
if video.isLocal, video.localStreamIsFile {
if url.startAccessingSecurityScopedResource() {
URLBookmarkModel.shared.saveBookmark(url)
}
}
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
}
} else {
self.onFileLoaded = {
updateCurrentStream()
startPlaying()
}
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true
self?.pause()
}
}
}
}
if preservingTime {
if model.preservedTime.isNil {
model.saveTime {
replaceItem(self.model.preservedTime)
}
} else {
replaceItem(self.model.preservedTime)
}
} else {
replaceItem(nil)
}
startClientUpdates()
}
func play() {
isPlaying = true
startClientUpdates()
if controls.presentingControls {
startControlsUpdates()
}
setRate(model.currentRate)
client?.play()
}
func pause() {
isPlaying = false
stopClientUpdates()
client?.pause()
}
func togglePlay() {
if isPlaying {
pause()
} else {
play()
}
}
func cancelLoads() {
stop()
}
func stop() {
client?.stop()
}
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
client?.seek(to: time) { [weak self] _ in
self?.getTimeUpdates()
self?.updateControls()
completionHandler?(true)
}
}
func setRate(_ rate: Double) {
client?.setDoubleAsync("speed", rate)
}
func closeItem() {
client?.pause()
client?.stop()
self.video = nil
self.stream = nil
}
func closePiP() {}
func startControlsUpdates() {
guard model.presentingPlayer, model.controls.presentingControls, !model.controls.presentingOverlays else {
self.logger.info("ignored controls update start")
return
}
self.logger.info("starting controls updates")
controlsUpdates = true
}
func stopControlsUpdates() {
self.logger.info("stopping controls updates")
controlsUpdates = false
}
func startClientUpdates() {
clientTimer.start()
}
private var handleSegmentsThrottle = Throttle(interval: 1)
func getTimeUpdates() {
currentTime = client?.currentTime
playerItemDuration = client?.duration
if controlsUpdates {
updateControls()
}
model.updateNowPlayingInfo()
handleSegmentsThrottle.execute {
if let currentTime {
model.handleSegments(at: currentTime)
}
}
timeObserverThrottle.execute {
self.model.updateWatch(time: self.currentTime)
}
self.model.updateTime(self.currentTime!)
}
private func stopClientUpdates() {
clientTimer.pause()
}
private func updateControlsIsPlaying() {
guard model.activeBackend == .mpv else { return }
DispatchQueue.main.async { [weak self] in
self?.controls.isPlaying = self?.isPlaying ?? false
}
}
func handle(_ event: UnsafePointer<mpv_event>!) {
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
switch event.pointee.event_id {
case MPV_EVENT_SHUTDOWN:
mpv_destroy(client.mpv)
client.mpv = nil
case MPV_EVENT_LOG_MESSAGE:
let logmsg = UnsafeMutablePointer<mpv_event_log_message>(OpaquePointer(event.pointee.data))
logger.info(.init(stringLiteral: "\(String(cString: (logmsg!.pointee.prefix)!)), "
+ "\(String(cString: (logmsg!.pointee.level)!)), "
+ "\(String(cString: (logmsg!.pointee.text)!))"))
case MPV_EVENT_FILE_LOADED:
onFileLoaded?()
startClientUpdates()
onFileLoaded = nil
case MPV_EVENT_PROPERTY_CHANGE:
let dataOpaquePtr = OpaquePointer(event.pointee.data)
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
let propertyName = String(cString: property.name)
handlePropertyChange(propertyName, property)
}
case MPV_EVENT_PLAYBACK_RESTART:
isLoadingVideo = false
isSeeking = false
onFileLoaded?()
startClientUpdates()
onFileLoaded = nil
case MPV_EVENT_VIDEO_RECONFIG:
model.updateAspectRatio()
case MPV_EVENT_SEEK:
isSeeking = true
case MPV_EVENT_END_FILE:
let reason = event!.pointee.data.load(as: mpv_end_file_reason.self)
if reason != MPV_END_FILE_REASON_STOP {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
NavigationModel.shared.presentAlert(title: "Error while opening file")
self.model.closeCurrentItem(finished: true)
self.getTimeUpdates()
self.eofPlaybackModeAction()
}
} else {
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
}
default:
logger.info(.init(stringLiteral: "UNHANDLED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
}
}
func handleEndOfFile() {
guard client.eofReached else {
return
}
getTimeUpdates()
eofPlaybackModeAction()
}
func setNeedsDrawing(_ needsDrawing: Bool) {
client?.setNeedsDrawing(needsDrawing)
}
func setSize(_ width: Double, _ height: Double) {
client?.setSize(width, height)
}
func addVideoTrack(_ url: URL) {
client?.addVideoTrack(url)
}
func addSubTrack(_ url: URL) {
client?.removeSubs()
client?.addSubTrack(url)
}
func setVideoToAuto() {
client?.setVideoToAuto()
}
func setVideoToNo() {
client?.setVideoToNo()
}
func updateNetworkState() {
DispatchQueue.main.async { [weak self] in
guard let self, let client = self.client else { return }
self.networkState.pausedForCache = client.pausedForCache
self.networkState.cacheDuration = client.cacheDuration
self.networkState.bufferingState = client.bufferingState
}
if !networkState.needsUpdates {
networkStateTimer.pause()
}
}
func setNeedsNetworkStateUpdates(_ needsUpdates: Bool) {
if needsUpdates {
networkStateTimer.start()
} else {
networkStateTimer.pause()
}
}
func startMusicMode() {
setVideoToNo()
}
func stopMusicMode() {
addVideoTrackFromStream()
setVideoToAuto()
controls.resetTimer()
}
func addVideoTrackFromStream() {
if let videoTrackURL = model.stream?.videoAsset?.url,
tracks < 2
{
logger.info("adding video track")
addVideoTrack(videoTrackURL)
}
setVideoToAuto()
}
func didChangeTo() {
setNeedsDrawing(model.presentingPlayer)
if model.musicMode {
startMusicMode()
} else {
stopMusicMode()
}
}
private func handlePropertyChange(_ name: String, _ property: mpv_event_property) {
switch name {
case "pause":
if let paused = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
if paused {
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
} else {
isLoadingVideo = false
isSeeking = false
}
isPlaying = !paused
networkStateTimer.start()
}
case "core-idle":
if let idle = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
if !idle {
isLoadingVideo = false
isSeeking = false
networkStateTimer.start()
}
}
default:
logger.info("MPV backend received unhandled property: \(name)")
}
}
}