HLS: set target bitrate / AVPlayer: higher resolution

HLS: try matching the set resolution. This works okay with AVPlayer. With MPV it is hit and miss, most of the time MPV targets the highest available bitrate, instead of the set bitrate.

AVPlayer now supports higher resolution up to 1080p60.
This commit is contained in:
Toni Förster 2024-05-13 07:54:24 +02:00
parent fba01e35a3
commit b54044cbc5
No known key found for this signature in database
GPG Key ID: 292F3E5086C83FC7
9 changed files with 79 additions and 9 deletions

View File

@ -654,7 +654,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue), resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
kind: .adaptive, kind: .adaptive,
encoding: videoStream["encoding"].string, encoding: videoStream["encoding"].string,
videoFormat: videoStream["type"].string videoFormat: videoStream["type"].string,
bitrate: videoStream["bitrate"].int
) )
} }
} }

View File

@ -687,6 +687,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30 let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
let resolution = Stream.Resolution.from(resolution: quality, fps: fps) let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
let videoFormat = videoStream.dictionaryValue["format"]?.string let videoFormat = videoStream.dictionaryValue["format"]?.string
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
if videoOnly { if videoOnly {
streams.append( streams.append(
@ -696,7 +697,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
videoAsset: videoAsset, videoAsset: videoAsset,
resolution: resolution, resolution: resolution,
kind: .adaptive, kind: .adaptive,
videoFormat: videoFormat videoFormat: videoFormat,
bitrate: bitrate
) )
) )
} else { } else {

View File

@ -298,6 +298,11 @@ final class AVPlayerBackend: PlayerBackend {
removeItemDidPlayToEndTimeObserver() removeItemDidPlayToEndTimeObserver()
model.playerItem = playerItem(stream) model.playerItem = playerItem(stream)
if stream.isHLS {
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
}
guard model.playerItem != nil else { guard model.playerItem != nil else {
return return
} }

View File

@ -286,7 +286,7 @@ final class MPVBackend: PlayerBackend {
} }
} }
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true self?.isLoadingVideo = true
} }
} else { } else {
@ -298,7 +298,7 @@ final class MPVBackend: PlayerBackend {
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
self?.isLoadingVideo = true self?.isLoadingVideo = true
self?.pause() self?.pause()
} }

View File

@ -128,6 +128,8 @@ final class MPVClient: ObservableObject {
func loadFile( func loadFile(
_ url: URL, _ url: URL,
audio: URL? = nil, audio: URL? = nil,
bitrate: Int? = nil,
kind: Stream.Kind,
sub: URL? = nil, sub: URL? = nil,
time: CMTime? = nil, time: CMTime? = nil,
forceSeekable: Bool = false, forceSeekable: Bool = false,
@ -160,6 +162,10 @@ final class MPVClient: ObservableObject {
args.append(options.joined(separator: ",")) args.append(options.joined(separator: ","))
} }
if kind == .hls, bitrate != 0 {
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
}
command("loadfile", args: args, returnValueCallback: completionHandler) command("loadfile", args: args, returnValueCallback: completionHandler)
} }

View File

@ -137,11 +137,17 @@ extension PlayerBackend {
// find max resolution from non HLS streams // find max resolution from non HLS streams
let bestResolution = nonHLSStreams let bestResolution = nonHLSStreams
.filter { $0.resolution <= maxResolution.value } .filter { $0.resolution <= maxResolution.value }
.max { $0.resolution < $1.resolution }?.resolution .max { $0.resolution < $1.resolution }
// finde max bitrate from non HLS streams
let bestBitrate = nonHLSStreams
.filter { $0.resolution <= maxResolution.value }
.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
return streams.map { stream in return streams.map { stream in
if stream.kind == .hls { if stream.kind == .hls {
stream.resolution = bestResolution ?? maxResolution.value stream.resolution = bestResolution?.resolution ?? maxResolution.value
stream.bitrate = bestBitrate?.bitrate ?? (bestResolution?.resolution.bitrate ?? maxResolution.value.bitrate)
stream.format = .hls stream.format = .hls
} else if stream.kind == .stream { } else if stream.kind == .stream {
stream.format = .stream stream.format = .stream

View File

@ -54,6 +54,32 @@ class Stream: Equatable, Hashable, Identifiable {
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1 return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
} }
// These values are an approximation.
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
var bitrate: Int {
switch self {
case .hd2160p60, .hd2160p50, .hd2160p48, .hd2160p30:
return 56000000 // 56 Mbit/s
case .hd1440p60, .hd1440p50, .hd1440p48, .hd1440p30:
return 24000000 // 24 Mbit/s
case .hd1080p60, .hd1080p50, .hd1080p48, .hd1080p30:
return 12000000 // 12 Mbit/s
case .hd720p60, .hd720p50, .hd720p48, .hd720p30:
return 9500000 // 9.5 Mbit/s
case .sd480p30:
return 4000000 // 4 Mbit/s
case .sd360p30:
return 1500000 // 1.5 Mbit/s
case .sd240p30:
return 1000000 // 1 Mbit/s
case .sd144p30:
return 600000 // 0.6 Mbit/s
case .unknown:
return 0
}
}
static func from(resolution: String, fps: Int? = nil) -> Self { static func from(resolution: String, fps: Int? = nil) -> Self {
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
} }
@ -143,6 +169,7 @@ class Stream: Equatable, Hashable, Identifiable {
var encoding: String? var encoding: String?
var videoFormat: String? var videoFormat: String?
var bitrate: Int?
init( init(
instance: Instance? = nil, instance: Instance? = nil,
@ -153,7 +180,8 @@ class Stream: Equatable, Hashable, Identifiable {
resolution: Resolution? = nil, resolution: Resolution? = nil,
kind: Kind = .hls, kind: Kind = .hls,
encoding: String? = nil, encoding: String? = nil,
videoFormat: String? = nil videoFormat: String? = nil,
bitrate: Int? = nil
) { ) {
self.instance = instance self.instance = instance
self.audioAsset = audioAsset self.audioAsset = audioAsset
@ -164,6 +192,7 @@ class Stream: Equatable, Hashable, Identifiable {
self.kind = kind self.kind = kind
self.encoding = encoding self.encoding = encoding
format = .from(videoFormat ?? "") format = .from(videoFormat ?? "")
self.bitrate = bitrate
} }
var isLocal: Bool { var isLocal: Bool {

View File

@ -5,6 +5,23 @@ import SwiftUI
enum Constants { enum Constants {
static let yatteeProtocol = "yattee://" static let yatteeProtocol = "yattee://"
static let overlayAnimation = Animation.linear(duration: 0.2) static let overlayAnimation = Animation.linear(duration: 0.2)
static var isAppleTV: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .tv
#else
false
#endif
}
static var isMac: Bool {
#if os(iOS)
UIDevice.current.userInterfaceIdiom == .mac
#else
false
#endif
}
static var isIPhone: Bool { static var isIPhone: Bool {
#if os(iOS) #if os(iOS)
UIDevice.current.userInterfaceIdiom == .phone UIDevice.current.userInterfaceIdiom == .phone

View File

@ -140,7 +140,11 @@ struct QualityProfileForm: View {
Text("Formats can be reordered and will be selected in this order.") Text("Formats can be reordered and will be selected in this order.")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text("**Note:** HLS is an adaptive format, resolution setting doesn't apply.") Text("**Note:** HLS is an adaptive format where specific resolution settings don't apply.")
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top)
Text("Yattee attempts to match the quality that is closest to the set resolution, but exact results cannot be guaranteed.")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.top, 0.1) .padding(.top, 0.1)
@ -301,7 +305,7 @@ struct QualityProfileForm: View {
func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool { func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool {
guard backend == .appleAVPlayer else { return false } guard backend == .appleAVPlayer else { return false }
return resolution.value > .hd720p30 return resolution.value > .hd1080p60
} }
func initializeForm() { func initializeForm() {