Hello, mpv! 🎉

This commit is contained in:
Arkadiusz Fal
2022-02-16 21:23:11 +01:00
parent 48ab8b27c8
commit 0a36870480
75 changed files with 6516 additions and 932 deletions

View File

@@ -403,7 +403,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
return []
}
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/mp4") && $0["encoding"].stringValue == "h264" }
let videoAssetsURLs = streams.filter { $0["type"].stringValue.starts(with: "video/") }
return videoAssetsURLs.map {
Stream(
@@ -411,7 +411,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
videoAsset: AVURLAsset(url: $0["url"].url!),
resolution: Stream.Resolution.from(resolution: $0["resolution"].stringValue),
kind: .adaptive,
encoding: $0["encoding"].stringValue
encoding: $0["encoding"].stringValue,
videoFormat: $0["type"].stringValue
)
}
}

View File

@@ -386,11 +386,19 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
streams.append(Stream(hlsURL: hlsURL))
}
guard let audioStream = compatibleAudioStreams(from: content).first else {
let audioStreams = content
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
} ?? []
guard let audioStream = audioStreams.first else {
return streams
}
let videoStreams = compatibleVideoStream(from: content)
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
videoStreams.forEach { videoStream in
let audioAsset = AVURLAsset(url: audioStream.dictionaryValue["url"]!.url!)
@@ -398,10 +406,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let videoOnly = videoStream.dictionaryValue["videoOnly"]?.boolValue ?? true
let resolution = Stream.Resolution.from(resolution: videoStream.dictionaryValue["quality"]!.stringValue)
let videoFormat = videoStream.dictionaryValue["format"]?.stringValue
if videoOnly {
streams.append(
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive)
Stream(audioAsset: audioAsset, videoAsset: videoAsset, resolution: resolution, kind: .adaptive, videoFormat: videoFormat)
)
} else {
streams.append(
@@ -420,23 +429,6 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
.compactMap(extractVideo(from:)) ?? []
}
private func compatibleAudioStreams(from content: JSON) -> [JSON] {
content
.dictionaryValue["audioStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
.sorted {
$0.dictionaryValue["bitrate"]?.intValue ?? 0 > $1.dictionaryValue["bitrate"]?.intValue ?? 0
} ?? []
}
private func compatibleVideoStream(from content: JSON) -> [JSON] {
content
.dictionaryValue["videoStreams"]?
.arrayValue
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
}
private func extractComment(from content: JSON) -> Comment? {
let details = content.dictionaryValue
let author = details["author"]?.stringValue ?? ""

View File

@@ -23,13 +23,14 @@ extension PlayerModel {
}
func updateWatch(finished: Bool = false) {
guard let id = currentVideo?.videoID else {
guard let id = currentVideo?.videoID,
Defaults[.saveHistory]
else {
return
}
let time = player.currentTime()
let seconds = time.seconds
currentItem.playbackTime = time
let time = backend.currentTime
let seconds = time?.seconds ?? 0
let watch: Watch!
let watchFetchRequest = Watch.fetchRequest()

View 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
}
}

View 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)
}
}

View 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, &params) < 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()
}

View 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)
}
}

View 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"
}
}
}

View File

@@ -0,0 +1,114 @@
import CoreMedia
import Foundation
import SwiftUI
final class PlayerControlsModel: ObservableObject {
@Published var isLoadingVideo = true
@Published var isPlaying = true
@Published var currentTime = CMTime.zero
@Published var duration = CMTime.zero
@Published var presentingControls = false { didSet { handlePresentationChange() } }
@Published var timer: Timer?
@Published var playingFullscreen = false
var player: PlayerModel!
var playbackTime: String {
guard let current = currentTime.seconds.formattedAsPlaybackTime(),
let duration = duration.seconds.formattedAsPlaybackTime()
else {
return "--:-- / --:--"
}
var withoutSegments = ""
if let withoutSegmentsDuration = playerItemDurationWithoutSponsorSegments,
self.duration.seconds != withoutSegmentsDuration
{
withoutSegments = " (\(withoutSegmentsDuration.formattedAsPlaybackTime() ?? "--:--"))"
}
return "\(current) / \(duration)\(withoutSegments)"
}
var playerItemDurationWithoutSponsorSegments: Double? {
guard let duration = player.playerItemDurationWithoutSponsorSegments else {
return nil
}
return duration.seconds
}
func handlePresentationChange() {
if presentingControls {
DispatchQueue.main.async { [weak self] in
self?.player.backend.startControlsUpdates()
self?.resetTimer()
}
} else {
player.backend.stopControlsUpdates()
timer?.invalidate()
timer = nil
}
}
func show() {
withAnimation(PlayerControls.animation) {
player.backend.updateControls()
presentingControls = true
}
}
func hide() {
withAnimation(PlayerControls.animation) {
presentingControls = false
}
}
func toggle() {
withAnimation(PlayerControls.animation) {
if !presentingControls {
player.backend.updateControls()
}
presentingControls.toggle()
}
}
func toggleFullscreen(_ value: Bool) {
withAnimation(Animation.easeOut) {
resetTimer()
withAnimation(PlayerControls.animation) {
playingFullscreen = !value
}
if playingFullscreen {
guard !(UIApplication.shared.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true) else {
return
}
Orientation.lockOrientation(.landscape, andRotateTo: .landscapeRight)
} else {
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: .portrait)
}
}
}
func reset() {
currentTime = .zero
duration = .zero
}
func resetTimer() {
removeTimer()
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { _ in
withAnimation(PlayerControls.animation) { [weak self] in
self?.presentingControls = false
self?.player.backend.stopControlsUpdates()
}
}
}
func removeTimer() {
timer?.invalidate()
timer = nil
}
}

View File

@@ -16,18 +16,35 @@ import SwiftyJSON
final class PlayerModel: ObservableObject {
static let availableRates: [Float] = [0.5, 0.67, 0.8, 1, 1.25, 1.5, 2]
static let assetKeysToLoad = ["tracks", "playable", "duration"]
let logger = Logger(label: "stream.yattee.app")
private(set) var player = AVPlayer()
var playerView = Player()
var controller: PlayerViewController?
var avPlayerView = AVPlayerView()
var playerItem: AVPlayerItem?
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
var mpvPlayerView = MPVPlayerView()
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
@Published var activeBackend = PlayerBackendType.mpv
var avPlayerBackend: AVPlayerBackend!
var mpvBackend: MPVBackend!
var backends: [PlayerBackend] {
[avPlayerBackend, mpvBackend]
}
var backend: PlayerBackend! {
switch activeBackend {
case .mpv:
return mpvBackend
case .appleAVPlayer:
return avPlayerBackend
}
}
@Published var playerSize: CGSize = .zero
@Published var stream: Stream?
@Published var currentRate: Float = 1.0 { didSet { player.rate = currentRate } }
@Published var currentRate: Float = 1.0 { didSet { backend.setRate(currentRate) } }
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
@@ -55,24 +72,15 @@ final class PlayerModel: ObservableObject {
var accounts: AccountsModel
var comments: CommentsModel
var asset: AVURLAsset?
var composition = AVMutableComposition()
var loadedCompositionAssets = [AVMediaType]()
var controls: PlayerControlsModel { didSet {
backends.forEach { backend in
var backend = backend
backend.controls = controls
}
}}
var context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
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)
var playingInPictureInPicture = false
var playingFullscreen = false
@Published var playingInPictureInPicture = false
@Published var presentingErrorDetails = false
var playerError: Error? { didSet {
@@ -91,13 +99,15 @@ final class PlayerModel: ObservableObject {
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
#endif
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil) {
init(accounts: AccountsModel? = nil, comments: CommentsModel? = nil, controls: PlayerControlsModel? = nil) {
self.accounts = accounts ?? AccountsModel()
self.comments = comments ?? CommentsModel()
self.controls = controls ?? PlayerControlsModel()
addFrequentTimeObserver()
addInfrequentTimeObserver()
addPlayerTimeControlStatusObserver()
self.avPlayerBackend = AVPlayerBackend(model: self, controls: controls)
self.mpvBackend = MPVBackend(model: self)
self.activeBackend = Defaults[.activeBackend]
}
func show() {
@@ -139,11 +149,25 @@ final class PlayerModel: ObservableObject {
return false
}
return player.currentItem == nil || time == nil || !time!.isValid
return backend.isLoadingVideo
}
var isPlaying: Bool {
player.timeControlStatus == .playing
backend.isPlaying
}
var playerItemDuration: CMTime? {
backend.playerItemDuration
}
var playerItemDurationWithoutSponsorSegments: CMTime? {
(backend.playerItemDuration ?? .zero) - .secondsInDefaultTimescale(
sponsorBlock.segments.reduce(0) { $0 + $1.duration }
)
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length ?? playerItemDuration?.seconds
}
var time: CMTime? {
@@ -154,32 +178,16 @@ final class PlayerModel: ObservableObject {
currentVideo?.live ?? false
}
var playerItemDuration: CMTime? {
player.currentItem?.asset.duration
}
var videoDuration: TimeInterval? {
currentItem?.duration ?? currentVideo?.length ?? player.currentItem?.asset.duration.seconds
}
func togglePlay() {
isPlaying ? pause() : play()
backend.togglePlay()
}
func play() {
guard player.timeControlStatus != .playing else {
return
}
player.play()
backend.play()
}
func pause() {
guard player.timeControlStatus != .paused else {
return
}
player.pause()
backend.pause()
}
func play(_ video: Video, at time: TimeInterval? = nil, inNavigationView: Bool = false) {
@@ -210,7 +218,7 @@ final class PlayerModel: ObservableObject {
self?.sponsorBlock.loadSegments(
videoID: video.videoID,
categories: Defaults[.sponsorBlockCategories]
) { [weak self] in
) {
if Defaults[.showChannelSubscribers] {
self?.loadCurrentItemChannelDetails()
}
@@ -218,24 +226,29 @@ final class PlayerModel: ObservableObject {
}
}
if let url = stream.singleAssetURL {
logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
} else {
logger.info("playing stream with many assets:")
logger.info("composition audio asset: \(stream.audioAsset.url)")
logger.info("composition video asset: \(stream.videoAsset.url)")
controls.reset()
loadComposition(stream, of: video, preservingTime: preservingTime)
backend.playStream(
stream,
of: video,
preservingTime: preservingTime,
upgrading: upgrading
)
}
func saveTime(completionHandler: @escaping () -> Void = {}) {
guard let currentTime = backend.currentTime, currentTime.seconds > 0 else {
return
}
if !upgrading {
updateCurrentArtwork()
DispatchQueue.main.async { [weak self] in
self?.preservedTime = currentTime
completionHandler()
}
}
func upgradeToStream(_ stream: Stream) {
if !self.stream.isNil, self.stream != stream {
func upgradeToStream(_ stream: Stream, force: Bool = false) {
if !self.stream.isNil, force || self.stream != stream {
playStream(stream, of: currentVideo!, preservingTime: true, upgrading: true)
}
}
@@ -260,6 +273,9 @@ final class PlayerModel: ObservableObject {
}
private func handlePresentationChange() {
backend.setNeedsDrawing(presentingPlayer)
controls.hide()
if presentingPlayer, closePiPOnOpeningPlayer, playingInPictureInPicture {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.closePiP()
@@ -272,7 +288,7 @@ final class PlayerModel: ObservableObject {
}
}
if !presentingPlayer, !pauseOnHidingPlayer, isPlaying {
if !presentingPlayer, !pauseOnHidingPlayer, backend.isPlaying {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
@@ -287,425 +303,49 @@ final class PlayerModel: ObservableObject {
}
}
private func insertPlayerItem(
_ stream: Stream,
for video: Video,
preservingTime: Bool = false
) {
removeItemDidPlayToEndTimeObserver()
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType) {
Defaults[.activeBackend] = to
self.activeBackend = to
playerItem = playerItem(stream)
guard playerItem != nil else {
guard var stream = stream else {
return
}
addItemDidPlayToEndTimeObserver()
attachMetadata(to: playerItem!, video: video, for: stream)
inactiveBackends().forEach { $0.pause() }
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
self.stream = stream
self.composition = AVMutableComposition()
self.asset = nil
}
let startPlaying = {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
if self.isAutoplaying(self.playerItem!) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
guard let self = self else {
return
}
if !preservingTime,
let segment = self.sponsorBlock.segments.first,
segment.start < 3,
self.lastSkipped.isNil
{
self.player.seek(
to: segment.endTime,
toleranceBefore: .secondsInDefaultTimescale(1),
toleranceAfter: .zero
) { finished in
guard finished else {
return
}
self.lastSkipped = segment
self.play()
}
} else {
self.play()
}
}
}
}
let replaceItemAndSeek = {
guard video == self.currentVideo else {
return
}
self.player.replaceCurrentItem(with: self.playerItem)
self.seekToPreservedTime { finished in
if let stream = toBackend.stream, toBackend.video == fromBackend.video {
toBackend.seek(to: fromBackend.currentTime?.seconds ?? .zero) { finished in
guard finished else {
return
}
self.preservedTime = nil
startPlaying()
}
}
if preservingTime {
if preservedTime.isNil {
saveTime {
replaceItemAndSeek()
startPlaying()
}
} else {
replaceItemAndSeek()
startPlaying()
}
} else {
player.replaceCurrentItem(with: playerItem)
startPlaying()
}
}
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?.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)
loadCompositionAsset(stream.videoAsset, stream: stream, type: .video, of: video, preservingTime: preservingTime)
}
private func loadCompositionAsset(
_ asset: AVURLAsset,
stream: Stream,
type: AVMediaType,
of video: Video,
preservingTime: Bool = false
) {
asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
guard let self = self else {
return
}
self.logger.info("loading \(type.rawValue) track")
let assetTracks = asset.tracks(withMediaType: type)
guard let compositionTrack = self.composition.addMutableTrack(
withMediaType: type,
preferredTrackID: kCMPersistentTrackID_Invalid
) else {
self.logger.critical("composition \(type.rawValue) addMutableTrack FAILED")
return
toBackend.play()
}
guard let assetTrack = assetTracks.first else {
self.logger.critical("asset \(type.rawValue) track FAILED")
return
}
self.stream = stream
streamSelection = stream
try! compositionTrack.insertTimeRange(
CMTimeRange(start: .zero, duration: CMTime.secondsInDefaultTimescale(video.length)),
of: assetTrack,
at: .zero
)
self.logger.critical("\(type.rawValue) LOADED")
guard self.streamSelection == stream else {
self.logger.critical("IGNORING LOADED")
return
}
self.loadedCompositionAssets.append(type)
if self.loadedCompositionAssets.count == 2 {
self.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
}
}
}
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)
}
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.play()
}
case .failed:
self.playerError = item.error
default:
return
}
}
}
#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
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() {
prepareCurrentItemForHistory(finished: true)
if queue.isEmpty {
#if !os(macOS)
try? AVAudioSession.sharedInstance().setActive(false)
#endif
resetQueue()
#if os(tvOS)
controller?.playerView.dismiss(animated: false) { [weak self] in
self?.controller?.dismiss(animated: true)
}
#else
hide()
#endif
} else {
advanceToNextItem()
}
}
private func saveTime(completionHandler: @escaping () -> Void = {}) {
let currentTime = player.currentTime()
guard currentTime.seconds > 0 else {
return
}
DispatchQueue.main.async { [weak self] in
self?.preservedTime = currentTime
completionHandler()
}
}
private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) {
guard let time = preservedTime else {
return
}
player.seek(
to: time,
toleranceBefore: .secondsInDefaultTimescale(1),
toleranceAfter: .zero,
completionHandler: completionHandler
)
}
private func addFrequentTimeObserver() {
let interval = CMTime.secondsInDefaultTimescale(0.5)
frequentTimeObserver = player.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] _ in
guard let self = self else {
if !backend.canPlay(stream) {
guard let preferredStream = preferredStream(availableStreams) else {
return
}
guard !self.currentItem.isNil else {
return
}
stream = preferredStream
streamSelection = preferredStream
}
#if !os(tvOS)
self.updateNowPlayingInfo()
#endif
self.handleSegments(at: self.player.currentTime())
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.upgradeToStream(stream, force: true)
}
}
private func addInfrequentTimeObserver() {
let interval = CMTime.secondsInDefaultTimescale(5)
infrequentTimeObserver = player.addPeriodicTimeObserver(
forInterval: interval,
queue: .main
) { [weak self] _ in
guard let self = self else {
return
}
guard !self.currentItem.isNil else {
return
}
self.timeObserverThrottle.execute {
self.updateWatch()
}
}
}
private func addPlayerTimeControlStatusObserver() {
playerTimeControlStatusObserver = player.observe(\.timeControlStatus) { [weak self] player, _ in
guard let self = self,
self.player == player
else {
return
}
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate {
self.objectWillChange.send()
}
if player.timeControlStatus == .playing, player.rate != self.currentRate {
player.rate = self.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.updateWatch()
}
}
}
fileprivate func updateNowPlayingInfo() {
var nowPlayingInfo: [String: AnyObject] = [
MPMediaItemPropertyTitle: currentItem.video.title as AnyObject,
MPMediaItemPropertyArtist: currentItem.video.author as AnyObject,
MPNowPlayingInfoPropertyIsLiveStream: currentItem.video.live as AnyObject,
MPNowPlayingInfoPropertyElapsedPlaybackTime: player.currentTime().seconds as AnyObject,
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
]
if !currentArtwork.isNil {
nowPlayingInfo[MPMediaItemPropertyArtwork] = currentArtwork as AnyObject
}
if !currentItem.video.live {
let itemDuration = currentItem.videoDuration ?? currentItem.duration
let duration = itemDuration.isFinite ? Double(itemDuration) : nil
if !duration.isNil {
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration as AnyObject
}
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
}
private func updateCurrentArtwork() {
guard let thumbnailData = try? Data(contentsOf: 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! }
private func inactiveBackends() -> [PlayerBackend] {
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
}
func loadCurrentItemChannelDetails() {
@@ -749,7 +389,8 @@ final class PlayerModel: ObservableObject {
func closeCurrentItem() {
prepareCurrentItemForHistory()
currentItem = nil
player.replaceCurrentItem(with: nil)
backend.closeItem()
}
func closePiP() {
@@ -764,46 +405,9 @@ final class PlayerModel: ObservableObject {
show()
#endif
doClosePiP(wasPlaying: wasPlaying)
backend.closePiP(wasPlaying: wasPlaying)
}
#if os(tvOS)
private func doClosePiP(wasPlaying: Bool) {
let item = player.currentItem
let time = player.currentTime()
self.player.replaceCurrentItem(with: nil)
guard !item.isNil else {
return
}
self.player.seek(to: time)
self.player.replaceCurrentItem(with: item)
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.play()
}
}
#else
private func doClosePiP(wasPlaying: Bool) {
controller?.playerView.player = nil
controller?.playerView.player = player
guard wasPlaying else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.play()
}
}
#endif
func handleCurrentItemChange() {
#if os(macOS)
Windows.player.window?.title = windowTitle
@@ -827,25 +431,23 @@ final class PlayerModel: ObservableObject {
}
func enterFullScreen() {
guard !playingFullscreen else {
guard !controls.playingFullscreen else {
return
}
logger.info("entering fullscreen")
controller?.playerView
.perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: false, with: nil)
backend.enterFullScreen()
}
func exitFullScreen() {
guard playingFullscreen else {
guard controls.playingFullscreen else {
return
}
logger.info("exiting fullscreen")
controller?.playerView
.perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: false, with: nil)
backend.exitFullScreen()
}
#endif
}

View File

@@ -56,7 +56,7 @@ extension PlayerModel {
func playItem(_ item: PlayerQueueItem, video: Video? = nil, at time: TimeInterval? = nil) {
if !playingInPictureInPicture {
player.replaceCurrentItem(with: nil)
backend.closeItem()
}
comments.reset()
@@ -93,13 +93,13 @@ extension PlayerModel {
streams = streams.filter { $0.instance.id == id }
}
streams = streams.filter { backend.canPlay($0) }
switch quality {
case .best:
return streams.first { $0.kind == .hls } ??
streams.filter { $0.kind == .stream }.max { $0.resolution < $1.resolution } ??
streams.first
return backend.bestPlayable(streams)
default:
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }
let sorted = streams.filter { $0.kind != .hls }.sorted { $0.resolution > $1.resolution }.sorted { $0.kind < $1.kind }
return sorted.first(where: { $0.resolution.height <= quality.value.height })
}
}
@@ -118,7 +118,7 @@ extension PlayerModel {
remove(newItem)
currentItem = newItem
player.pause()
pause()
accounts.api.loadDetails(newItem) { newItem in
self.playItem(newItem, video: newItem.video, at: time)
@@ -144,11 +144,7 @@ extension PlayerModel {
self.removeQueueItems()
}
player.replaceCurrentItem(with: nil)
}
func isAutoplaying(_ item: AVPlayerItem) -> Bool {
player.currentItem == item
backend.closeItem()
}
@discardableResult func enqueueVideo(
@@ -163,7 +159,7 @@ extension PlayerModel {
if play {
currentItem = item
// pause playing current video as it's going to be replaced with next one
player.pause()
pause()
}
queue.insert(item, at: prepending ? 0 : queue.endIndex)

View File

@@ -38,9 +38,12 @@ extension PlayerModel {
return
}
player.seek(to: segment.endTime)
lastSkipped = segment
segmentRestorationTime = time
backend.seek(to: segment.endTime)
DispatchQueue.main.async { [weak self] in
self?.lastSkipped = segment
self?.segmentRestorationTime = time
}
logger.info("SponsorBlock skipping to: \(segment.end)")
}
@@ -63,13 +66,15 @@ extension PlayerModel {
}
restoredSegments.append(segment)
player.seek(to: time)
backend.seek(to: time)
resetLastSegment()
}
private func resetLastSegment() {
lastSkipped = nil
segmentRestorationTime = nil
DispatchQueue.main.async { [weak self] in
self?.lastSkipped = nil
self?.segmentRestorationTime = nil
}
}
func resetSegments() {

View File

@@ -17,6 +17,10 @@ class Segment: ObservableObject, Hashable {
segment.last!
}
var duration: Double {
end - start
}
var endTime: CMTime {
CMTime(seconds: end, preferredTimescale: 1000)
}

View File

@@ -5,7 +5,7 @@ import Foundation
// swiftlint:disable:next final_class
class Stream: Equatable, Hashable, Identifiable {
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
case hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
case hd2160p, hd1440p60, hd1440p, hd1080p60, hd1080p, hd720p60, hd720p, sd480p, sd360p, sd240p, sd144p, unknown
var name: String {
"\(height)p\(refreshRate != -1 ? ", \(refreshRate) fps" : "")"
@@ -68,6 +68,7 @@ class Stream: Equatable, Hashable, Identifiable {
var kind: Kind!
var encoding: String!
var videoFormat: String!
init(
instance: Instance? = nil,
@@ -76,7 +77,8 @@ class Stream: Equatable, Hashable, Identifiable {
hlsURL: URL? = nil,
resolution: Resolution? = nil,
kind: Kind = .hls,
encoding: String? = nil
encoding: String? = nil,
videoFormat: String? = nil
) {
self.instance = instance
self.audioAsset = audioAsset
@@ -85,14 +87,35 @@ class Stream: Equatable, Hashable, Identifiable {
self.resolution = resolution
self.kind = kind
self.encoding = encoding
self.videoFormat = videoFormat
}
var quality: String {
kind == .hls ? "adaptive (HLS)" : "\(resolution.name) \(kind == .stream ? "(\(kind.rawValue))" : "")"
if resolution == .hd2160p {
return "4K (2160p)"
}
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
}
var format: String {
let lowercasedFormat = (videoFormat ?? "unknown").lowercased()
if lowercasedFormat.contains("webm") {
return "WEBM"
} else if lowercasedFormat.contains("avc1") {
return "avc1"
} else if lowercasedFormat.contains("av01") {
return "AV1"
} else if lowercasedFormat.contains("mpeg_4") || lowercasedFormat.contains("mp4") {
return "MP4"
} else {
return lowercasedFormat
}
}
var description: String {
"\(quality) - \(instance?.description ?? "")"
let formatString = format == "unknown" ? "" : " (\(format))"
return "\(quality)\(formatString) - \(instance?.description ?? "")"
}
var assets: [AVURLAsset] {