mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Hello, mpv! 🎉
This commit is contained in:
613
Model/Player/Backends/AVPlayerBackend.swift
Normal file
613
Model/Player/Backends/AVPlayerBackend.swift
Normal file
@@ -0,0 +1,613 @@
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import Foundation
|
||||
import MediaPlayer
|
||||
import UIKit
|
||||
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
|
||||
var currentTime: CMTime? {
|
||||
avPlayer.currentTime()
|
||||
}
|
||||
|
||||
var loadedVideo: Bool {
|
||||
!avPlayer.currentItem.isNil
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
model.currentItem == nil || model.time == nil || !model.time!.isValid
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
avPlayer.currentItem?.asset.duration
|
||||
}
|
||||
|
||||
private(set) var avPlayer = AVPlayer()
|
||||
var controller: AppleAVPlayerViewController?
|
||||
|
||||
private var asset: AVURLAsset?
|
||||
private var composition = AVMutableComposition()
|
||||
private var loadedCompositionAssets = [AVMediaType]()
|
||||
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel?) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
addFrequentTimeObserver()
|
||||
addInfrequentTimeObserver()
|
||||
addPlayerTimeControlStatusObserver()
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream]) -> Stream? {
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.filter { $0.kind == .adaptive }.max { $0.resolution < $1.resolution } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream || stream.videoFormat == "MPEG_4" ||
|
||||
(stream.videoFormat.starts(with: "video/mp4") && stream.encoding == "h264")
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
) {
|
||||
if let url = stream.singleAssetURL {
|
||||
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
model.logger.info("composition video asset: \(stream.videoAsset.url)")
|
||||
|
||||
loadComposition(stream, of: video, preservingTime: preservingTime)
|
||||
}
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard avPlayer.timeControlStatus != .playing else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard avPlayer.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler ?? { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
if let currentTime = currentTime {
|
||||
seek(to: currentTime + time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
avPlayer.rate = rate
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
}
|
||||
|
||||
func enterFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen() {
|
||||
controller?.playerView
|
||||
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
let item = avPlayer.currentItem
|
||||
let time = avPlayer.currentTime()
|
||||
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
|
||||
guard !item.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(to: time)
|
||||
avPlayer.replaceCurrentItem(with: item)
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#else
|
||||
func closePiP(wasPlaying: Bool) {
|
||||
controller?.playerView.player = nil
|
||||
controller?.playerView.player = avPlayer
|
||||
|
||||
guard wasPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.play()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func updateControls() {}
|
||||
func startControlsUpdates() {}
|
||||
func stopControlsUpdates() {}
|
||||
func setNeedsDrawing(_: Bool) {}
|
||||
|
||||
private func loadSingleAsset(
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.playerError = error
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadComposition(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
loadedCompositionAssets = []
|
||||
loadCompositionAsset(stream.audioAsset, stream: stream, type: .audio, of: video, preservingTime: preservingTime, model: model)
|
||||
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime, model: model)
|
||||
}
|
||||
|
||||
private func loadCompositionAsset(
|
||||
_ asset: AVURLAsset,
|
||||
stream: Stream,
|
||||
type: AVMediaType,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false,
|
||||
model: PlayerModel
|
||||
) {
|
||||
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
model.logger.info("loading \(type.rawValue) track")
|
||||
|
||||
let assetTracks = asset.tracks(withMediaType: type)
|
||||
|
||||
guard let compositionTrack = self.composition.addMutableTrack(
|
||||
withMediaType: type,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
) else {
|
||||
model.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
guard let assetTrack = assetTracks.first else {
|
||||
model.logger.critical("asset \(type.rawValue) track FAILED")
|
||||
return
|
||||
}
|
||||
|
||||
try! compositionTrack.insertTimeRange(
|
||||
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
|
||||
of: assetTrack,
|
||||
at: .zero
|
||||
)
|
||||
|
||||
model.logger.critical("\(type.rawValue) LOADED")
|
||||
|
||||
guard model.streamSelection == stream else {
|
||||
model.logger.critical("IGNORING LOADED")
|
||||
return
|
||||
}
|
||||
|
||||
self.loadedCompositionAssets.append(type)
|
||||
|
||||
if self.loadedCompositionAssets.count == 2 {
|
||||
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
addItemDidPlayToEndTimeObserver()
|
||||
attachMetadata(to: model.playerItem!, video: video, for: stream)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.stream = stream
|
||||
self.video = video
|
||||
self.model.stream = stream
|
||||
self.composition = AVMutableComposition()
|
||||
self.asset = nil
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
if self.isAutoplaying(self.model.playerItem!) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.avPlayer.seek(
|
||||
to: segment.endTime,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero
|
||||
) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.model.play()
|
||||
}
|
||||
} else {
|
||||
self.model.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItemAndSeek = {
|
||||
guard video == self.model.currentVideo else {
|
||||
return
|
||||
}
|
||||
self.avPlayer.replaceCurrentItem(with: self.model.playerItem)
|
||||
self.seekToPreservedTime { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
self.model.preservedTime = nil
|
||||
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
}
|
||||
} else {
|
||||
avPlayer.replaceCurrentItem(with: model.playerItem)
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
|
||||
guard let time = model.preservedTime else {
|
||||
return
|
||||
}
|
||||
|
||||
avPlayer.seek(
|
||||
to: time,
|
||||
toleranceBefore: .secondsInDefaultTimescale(1),
|
||||
toleranceAfter: .zero,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset = asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachMetadata(to item: AVPlayerItem, video: Video, for _: Stream? = nil) {
|
||||
#if !os(macOS)
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
|
||||
]
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
let pngData = image.pngData()
|
||||
{
|
||||
let artworkItem = makeMetadataItem(.commonIdentifierArtwork, value: pngData)
|
||||
externalMetadata.append(artworkItem)
|
||||
}
|
||||
|
||||
item.externalMetadata = externalMetadata
|
||||
#endif
|
||||
|
||||
item.preferredForwardBufferDuration = 5
|
||||
|
||||
observePlayerItemStatus(item)
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func makeMetadataItem(_ identifier: AVMetadataIdentifier, value: Any) -> AVMetadataItem {
|
||||
let item = AVMutableMetadataItem()
|
||||
|
||||
item.identifier = identifier
|
||||
item.value = value as? NSCopying & NSObjectProtocol
|
||||
item.extendedLanguageTag = "und"
|
||||
|
||||
return item.copy() as! AVMetadataItem
|
||||
}
|
||||
#endif
|
||||
|
||||
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
|
||||
avPlayer.currentItem == item
|
||||
}
|
||||
|
||||
private func observePlayerItemStatus(_ item: AVPlayerItem) {
|
||||
statusObservation?.invalidate()
|
||||
statusObservation = item.observe(\.status, options: [.old, .new]) { [weak self] playerItem, _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.isAutoplaying(playerItem) {
|
||||
self.model.play()
|
||||
}
|
||||
case .failed:
|
||||
self.model.playerError = item.error
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(itemDidPlayToEndTime),
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
private func removeItemDidPlayToEndTimeObserver() {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem
|
||||
)
|
||||
}
|
||||
|
||||
@objc func itemDidPlayToEndTime() {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
#if os(tvOS)
|
||||
controller?.playerView.dismiss(animated: false) { [weak self] in
|
||||
self?.controller?.dismiss(animated: true)
|
||||
}
|
||||
#else
|
||||
model.hide()
|
||||
#endif
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
private func addFrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(0.5)
|
||||
|
||||
frequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.controls.duration = self.playerItemDuration ?? .zero
|
||||
self.controls.currentTime = self.currentTime ?? .zero
|
||||
|
||||
#if !os(tvOS)
|
||||
self.updateNowPlayingInfo()
|
||||
#endif
|
||||
|
||||
if let currentTime = self.currentTime {
|
||||
self.model.handleSegments(at: currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addInfrequentTimeObserver() {
|
||||
let interval = CMTime.secondsInDefaultTimescale(5)
|
||||
|
||||
infrequentTimeObserver = avPlayer.addPeriodicTimeObserver(
|
||||
forInterval: interval,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.model.currentItem.isNil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addPlayerTimeControlStatusObserver() {
|
||||
playerTimeControlStatusObserver = avPlayer.observe(\.timeControlStatus) { [weak self] player, _ in
|
||||
guard let self = self,
|
||||
self.avPlayer == player
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.controls.isPlaying = player.timeControlStatus == .playing
|
||||
}
|
||||
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.model.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
if player.timeControlStatus == .playing, player.rate != self.model.currentRate {
|
||||
player.rate = self.model.currentRate
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
if player.timeControlStatus == .playing {
|
||||
ScreenSaverManager.shared.disable(reason: "Yattee is playing video")
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCurrentArtwork() {
|
||||
guard let thumbnailData = try? Data(contentsOf: model.currentItem.video.thumbnailURL(quality: .medium)!) else {
|
||||
return
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let image = NSImage(data: thumbnailData)
|
||||
#else
|
||||
let image = UIImage(data: thumbnailData)
|
||||
#endif
|
||||
|
||||
if image.isNil {
|
||||
return
|
||||
}
|
||||
|
||||
currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
|
||||
}
|
||||
|
||||
fileprivate func updateNowPlayingInfo() {
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: model.currentItem.video.title as AnyObject,
|
||||
MPMediaItemPropertyArtist: model.currentItem.video.author as AnyObject,
|
||||
MPNowPlayingInfoPropertyIsLiveStream: model.currentItem.video.live as AnyObject,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: avPlayer.currentTime().seconds as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: model.queue.count as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
|
||||
}
|
||||
|
||||
if !model.currentItem.video.live {
|
||||
let itemDuration = model.currentItem.videoDuration ?? model.currentItem.duration
|
||||
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
|
||||
|
||||
if !duration.isNil {
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
|
||||
}
|
||||
}
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
}
|
||||
}
|
305
Model/Player/Backends/MPVBackend.swift
Normal file
305
Model/Player/Backends/MPVBackend.swift
Normal file
@@ -0,0 +1,305 @@
|
||||
import AVFAudio
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Logging
|
||||
import SwiftUI
|
||||
|
||||
final class MPVBackend: PlayerBackend {
|
||||
private var logger = Logger(label: "mpv-backend")
|
||||
|
||||
var model: PlayerModel!
|
||||
var controls: PlayerControlsModel!
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
var currentTime: CMTime?
|
||||
|
||||
var loadedVideo = false
|
||||
var isLoadingVideo = true
|
||||
|
||||
var isPlaying = true { didSet {
|
||||
if isPlaying {
|
||||
startClientUpdates()
|
||||
} else {
|
||||
stopControlsUpdates()
|
||||
}
|
||||
|
||||
updateControlsIsPlaying()
|
||||
}}
|
||||
var playerItemDuration: CMTime?
|
||||
|
||||
var controller: MPVViewController!
|
||||
var client: MPVClient! { didSet { client.backend = self } }
|
||||
|
||||
private var clientTimer: RepeatingTimer!
|
||||
|
||||
private var onFileLoaded: (() -> Void)?
|
||||
|
||||
private var controlsUpdates = false
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
init(model: PlayerModel, controls: PlayerControlsModel? = nil) {
|
||||
self.model = model
|
||||
self.controls = controls
|
||||
|
||||
clientTimer = .init(timeInterval: 1)
|
||||
clientTimer.eventHandler = getClientUpdates
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream]) -> Stream? {
|
||||
streams.filter { $0.kind == .adaptive }.max { $0.resolution < $1.resolution } ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != "AV1"
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading _: Bool) {
|
||||
let updateCurrentStream = {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.stream = stream
|
||||
self?.video = video
|
||||
self?.model.stream = stream
|
||||
}
|
||||
}
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
if !preservingTime,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
segment.start < 3,
|
||||
self.model.lastSkipped.isNil
|
||||
{
|
||||
self.seek(to: segment.endTime) { finished in
|
||||
guard finished else {
|
||||
return
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.play()
|
||||
}
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replaceItem: (CMTime?) -> Void = { [weak self] time in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.stop()
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
self.onFileLoaded = { [weak self] in
|
||||
self?.setIsLoadingVideo(false)
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
|
||||
self.client.loadFile(url, time: time) { [weak self] _ in
|
||||
self?.setIsLoadingVideo(true)
|
||||
}
|
||||
} else {
|
||||
self.onFileLoaded = { [weak self] in
|
||||
self?.client.addAudio(stream.audioAsset.url) { _ in
|
||||
self?.setIsLoadingVideo(false)
|
||||
updateCurrentStream()
|
||||
startPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
self.client.loadFile(stream.videoAsset.url, time: time) { [weak self] _ in
|
||||
self?.setIsLoadingVideo(true)
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
} else {
|
||||
replaceItem(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
startClientUpdates()
|
||||
|
||||
client?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
stopClientUpdates()
|
||||
|
||||
client?.pause()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
client?.stop()
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?) {
|
||||
client.seek(to: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
client.seek(relative: time) { [weak self] _ in
|
||||
self?.getClientUpdates()
|
||||
self?.updateControls()
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setRate(_: Float) {
|
||||
// TODO: Implement rate change
|
||||
}
|
||||
|
||||
func closeItem() {}
|
||||
|
||||
func enterFullScreen() {}
|
||||
|
||||
func exitFullScreen() {}
|
||||
|
||||
func closePiP(wasPlaying _: Bool) {}
|
||||
|
||||
func updateControls() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.logger.info("updating controls")
|
||||
self?.controls.currentTime = self?.currentTime ?? .zero
|
||||
self?.controls.duration = self?.playerItemDuration ?? .zero
|
||||
}
|
||||
}
|
||||
|
||||
func startControlsUpdates() {
|
||||
self.logger.info("starting controls updates")
|
||||
controlsUpdates = true
|
||||
}
|
||||
|
||||
func stopControlsUpdates() {
|
||||
self.logger.info("stopping controls updates")
|
||||
controlsUpdates = false
|
||||
}
|
||||
|
||||
func startClientUpdates() {
|
||||
clientTimer.resume()
|
||||
}
|
||||
|
||||
private func getClientUpdates() {
|
||||
self.logger.info("getting client updates")
|
||||
|
||||
currentTime = client?.currentTime
|
||||
playerItemDuration = client?.duration
|
||||
|
||||
if controlsUpdates {
|
||||
updateControls()
|
||||
}
|
||||
|
||||
if let currentTime = currentTime {
|
||||
model.handleSegments(at: currentTime)
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
clientTimer.suspend()
|
||||
}
|
||||
|
||||
private func updateControlsIsPlaying() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.controls.isPlaying = self?.isPlaying ?? false
|
||||
}
|
||||
}
|
||||
|
||||
private func setIsLoadingVideo(_ value: Bool) {
|
||||
isLoadingVideo = value
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.controls.isLoadingVideo = value
|
||||
}
|
||||
}
|
||||
|
||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||
logger.info("\(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: "log: \(String(cString: (logmsg!.pointee.prefix)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.level)!)), "
|
||||
+ "\(String(cString: (logmsg!.pointee.text)!))"))
|
||||
|
||||
case MPV_EVENT_FILE_LOADED:
|
||||
onFileLoaded?()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_END_FILE:
|
||||
break
|
||||
// DispatchQueue.main.async { [weak self] in
|
||||
// TODO: handle EOF
|
||||
// self?.handleEndOfFile(event)
|
||||
// }
|
||||
|
||||
default:
|
||||
logger.info(.init(stringLiteral: "event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||
}
|
||||
}
|
||||
|
||||
func handleEndOfFile(_: UnsafePointer<mpv_event>!) {
|
||||
guard !isLoadingVideo else {
|
||||
return
|
||||
}
|
||||
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false)
|
||||
#endif
|
||||
model.resetQueue()
|
||||
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
client?.setNeedsDrawing(needsDrawing)
|
||||
}
|
||||
}
|
229
Model/Player/Backends/MPVClient.swift
Normal file
229
Model/Player/Backends/MPVClient.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import CoreMedia
|
||||
import Siesta
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
var queue: DispatchQueue!
|
||||
var glView: MPVOGLView!
|
||||
var backend: MPVBackend!
|
||||
|
||||
func create(frame: CGRect) -> MPVOGLView {
|
||||
glView = MPVOGLView(frame: frame)
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
checkError(mpv_request_log_messages(mpv, "warn"))
|
||||
checkError(mpv_initialize(mpv))
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", "yes"))
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "override-display-fps", "\(UIScreen.main.maximumFramesPerSecond)"))
|
||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress(_:_:),
|
||||
get_proc_address_ctx: nil,
|
||||
extra_exts: nil
|
||||
)
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_API_TYPE, data: api),
|
||||
mpv_render_param(type: MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, data: initParams),
|
||||
mpv_render_param()
|
||||
]
|
||||
|
||||
var mpvGL: OpaquePointer?
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
glView.mpvGL = UnsafeMutableRawPointer(mpvGL)
|
||||
|
||||
mpv_render_context_set_update_callback(
|
||||
mpvGL,
|
||||
glUpdate(_:),
|
||||
UnsafeMutableRawPointer(Unmanaged.passUnretained(glView).toOpaque())
|
||||
)
|
||||
}
|
||||
|
||||
queue = DispatchQueue(label: "mpv", qos: .background)
|
||||
queue!.async {
|
||||
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
}
|
||||
|
||||
return glView
|
||||
}
|
||||
|
||||
func readEvents() {
|
||||
queue?.async { [self] in
|
||||
while self.mpv != nil {
|
||||
let event = mpv_wait_event(self.mpv, 0)
|
||||
if event!.pointee.event_id == MPV_EVENT_NONE {
|
||||
break
|
||||
}
|
||||
backend.handle(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadFile(_ url: URL, time: CMTime? = nil, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
var args = [url.absoluteString]
|
||||
if let time = time {
|
||||
args.append("replace")
|
||||
args.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func addAudio(_ url: URL, completionHandler: ((Int32) -> Void)? = nil) {
|
||||
command("audio-add", args: [url.absoluteString], returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
func play() {
|
||||
setFlagAsync("pause", false)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
setFlagAsync("pause", true)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
command("cycle", args: ["pause"])
|
||||
}
|
||||
|
||||
func stop() {
|
||||
command("stop")
|
||||
}
|
||||
|
||||
var currentTime: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(getDouble("time-pos"))
|
||||
}
|
||||
|
||||
var duration: CMTime {
|
||||
CMTime.secondsInDefaultTimescale(getDouble("duration"))
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
command("seek", args: [String(time.seconds)]) { _ in
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
command("seek", args: [String(time.seconds), "absolute"]) { _ in
|
||||
completionHandler?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func setSize(_ width: Double, _ height: Double) {
|
||||
logger.info("setting player size to \(width),\(height)")
|
||||
#if !os(macOS)
|
||||
guard width <= UIScreen.main.bounds.width, height <= UIScreen.main.bounds.height else {
|
||||
logger.info("requested size is greater than screen size, ignoring")
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
glView?.frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
logger.info("needs drawing: \(needsDrawing)")
|
||||
glView.needsDrawing = needsDrawing
|
||||
}
|
||||
|
||||
func command(
|
||||
_ command: String,
|
||||
args: [String?] = [],
|
||||
checkForErrors: Bool = true,
|
||||
returnValueCallback: ((Int32) -> Void)? = nil
|
||||
) {
|
||||
guard mpv != nil else {
|
||||
return
|
||||
}
|
||||
var cargs = makeCArgs(command, args).map { $0.flatMap { UnsafePointer<CChar>(strdup($0)) } }
|
||||
defer {
|
||||
for ptr in cargs where ptr != nil {
|
||||
free(UnsafeMutablePointer(mutating: ptr!))
|
||||
}
|
||||
}
|
||||
logger.info("\(command) -- \(args)")
|
||||
let returnValue = mpv_command(mpv, &cargs)
|
||||
if checkForErrors {
|
||||
checkError(returnValue)
|
||||
}
|
||||
if let cb = returnValueCallback {
|
||||
cb(returnValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func setFlagAsync(_ name: String, _ flag: Bool) {
|
||||
var data: Int = flag ? 1 : 0
|
||||
mpv_set_property_async(mpv, 0, name, MPV_FORMAT_FLAG, &data)
|
||||
}
|
||||
|
||||
private func getDouble(_ name: String) -> Double {
|
||||
var data = Double()
|
||||
mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &data)
|
||||
return data
|
||||
}
|
||||
|
||||
private func makeCArgs(_ command: String, _ args: [String?]) -> [String?] {
|
||||
if !args.isEmpty, args.last == nil {
|
||||
fatalError("Command do not need a nil suffix")
|
||||
}
|
||||
|
||||
var strArgs = args
|
||||
strArgs.insert(command, at: 0)
|
||||
strArgs.append(nil)
|
||||
|
||||
return strArgs
|
||||
}
|
||||
|
||||
func checkError(_ status: CInt) {
|
||||
if status < 0 {
|
||||
logger.error(.init(stringLiteral: "MPV API error: \(String(cString: mpv_error_string(status)))\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getProcAddress(_: UnsafeMutableRawPointer?, _ name: UnsafePointer<Int8>?) -> UnsafeMutableRawPointer? {
|
||||
let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
|
||||
let addr = CFBundleGetFunctionPointerForName(CFBundleGetBundleWithIdentifier("com.apple.opengles" as CFString), symbolName)
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
private func glUpdate(_ ctx: UnsafeMutableRawPointer?) {
|
||||
let glView = unsafeBitCast(ctx, to: MPVOGLView.self)
|
||||
|
||||
guard glView.needsDrawing else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
glView.setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
private func wakeUp(_ context: UnsafeMutableRawPointer?) {
|
||||
let client = unsafeBitCast(context, to: MPVClient.self)
|
||||
client.readEvents()
|
||||
}
|
67
Model/Player/Backends/PlayerBackend.swift
Normal file
67
Model/Player/Backends/PlayerBackend.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
protocol PlayerBackend {
|
||||
var model: PlayerModel! { get set }
|
||||
var controls: PlayerControlsModel! { get set }
|
||||
|
||||
var stream: Stream? { get set }
|
||||
var video: Video? { get set }
|
||||
var currentTime: CMTime? { get }
|
||||
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var isPlaying: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream]) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading: Bool
|
||||
)
|
||||
|
||||
func play()
|
||||
func pause()
|
||||
func togglePlay()
|
||||
|
||||
func stop()
|
||||
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)?)
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)?)
|
||||
|
||||
func setRate(_ rate: Float)
|
||||
|
||||
func closeItem()
|
||||
|
||||
func enterFullScreen()
|
||||
func exitFullScreen()
|
||||
|
||||
func closePiP(wasPlaying: Bool)
|
||||
|
||||
func updateControls()
|
||||
func startControlsUpdates()
|
||||
func stopControlsUpdates()
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool)
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
func seek(to time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: time, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(to seconds: Double, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(to: .secondsInDefaultTimescale(seconds), completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
seek(relative: time, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
16
Model/Player/Backends/PlayerBackendType.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
enum PlayerBackendType: String, CaseIterable, Defaults.Serializable {
|
||||
case mpv
|
||||
case appleAVPlayer
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .mpv:
|
||||
return "MPV"
|
||||
case .appleAVPlayer:
|
||||
return "AVPlayer"
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user