mirror of
https://github.com/yattee/yattee.git
synced 2025-08-09 20:24:06 +00:00
Quality profiles
This commit is contained in:
@@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Float = 1.0 { didSet { backend.setRate(currentRate) } }
|
||||
|
||||
@Published var qualityProfileSelection: QualityProfile? { didSet { handleQualityProfileChange() }}
|
||||
|
||||
@Published var availableStreams = [Stream]() { didSet { handleAvailableStreamsChange() } }
|
||||
@Published var streamSelection: Stream? { didSet { rebuildTVMenu() } }
|
||||
|
||||
@@ -156,6 +158,7 @@ final class PlayerModel: ObservableObject {
|
||||
#endif
|
||||
}}
|
||||
|
||||
@Default(.qualityProfiles) var qualityProfiles
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@Default(.closePiPOnOpeningPlayer) var closePiPOnOpeningPlayer
|
||||
@@ -421,7 +424,11 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard let stream = preferredStream(availableStreams) else {
|
||||
if let qualityProfileBackend = qualityProfile?.backend, qualityProfileBackend != activeBackend {
|
||||
changeActiveBackend(from: activeBackend, to: qualityProfileBackend)
|
||||
}
|
||||
|
||||
guard let stream = streamByQualityProfile else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -445,12 +452,6 @@ final class PlayerModel: ObservableObject {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.backend.setNeedsDrawing(self.presentingPlayer)
|
||||
|
||||
#if os(tvOS)
|
||||
if self.presentingPlayer {
|
||||
self.controls.show()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
controls.hide()
|
||||
@@ -483,6 +484,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
pause()
|
||||
|
||||
Defaults[.activeBackend] = to
|
||||
self.activeBackend = to
|
||||
|
||||
@@ -496,8 +499,6 @@ final class PlayerModel: ObservableObject {
|
||||
musicMode = false
|
||||
}
|
||||
|
||||
inactiveBackends().forEach { $0.pause() }
|
||||
|
||||
let fromBackend: PlayerBackend = from == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
let toBackend: PlayerBackend = to == .appleAVPlayer ? avPlayerBackend : mpvBackend
|
||||
|
||||
@@ -516,7 +517,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
|
||||
guard let preferredStream = preferredStream(availableStreams) else {
|
||||
guard let preferredStream = streamByQualityProfile else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -533,8 +534,16 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func inactiveBackends() -> [PlayerBackend] {
|
||||
[activeBackend == PlayerBackendType.mpv ? avPlayerBackend : mpvBackend]
|
||||
func handleQualityProfileChange() {
|
||||
guard let profile = qualityProfile else { return }
|
||||
|
||||
if activeBackend != profile.backend { changeActiveBackend(from: activeBackend, to: profile.backend) }
|
||||
guard let profileStream = streamByQualityProfile, stream != profileStream else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.streamSelection = profileStream
|
||||
self?.upgradeToStream(profileStream)
|
||||
}
|
||||
}
|
||||
|
||||
func rateLabel(_ rate: Float) -> String {
|
||||
|
@@ -39,7 +39,7 @@ extension PlayerModel {
|
||||
func playItem(_ item: PlayerQueueItem, at time: CMTime? = nil) {
|
||||
advancing = false
|
||||
|
||||
if !playingInPictureInPicture {
|
||||
if !playingInPictureInPicture, !currentItem.isNil {
|
||||
backend.closeItem()
|
||||
}
|
||||
|
||||
@@ -70,8 +70,21 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func preferredStream(_ streams: [Stream]) -> Stream? {
|
||||
backend.bestPlayable(streams.filter { backend.canPlay($0) }, maxResolution: Defaults[.quality])
|
||||
var qualityProfile: QualityProfile? {
|
||||
qualityProfileSelection ?? QualityProfilesModel.shared.automaticProfile
|
||||
}
|
||||
|
||||
var streamByQualityProfile: Stream? {
|
||||
let profile = qualityProfile ?? .defaultProfile
|
||||
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution
|
||||
) {
|
||||
return streamPreferredForProfile
|
||||
}
|
||||
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
@@ -97,6 +110,8 @@ extension PlayerModel {
|
||||
|
||||
if let nextItem = nextItem {
|
||||
advanceToItem(nextItem)
|
||||
} else {
|
||||
advancing = false
|
||||
}
|
||||
}
|
||||
|
||||
|
112
Model/QualityProfile.swift
Normal file
112
Model/QualityProfile.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
static var bridge = QualityProfileBridge()
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
|
||||
static var highQualityProfile = Self(id: "highQuality", backend: .mpv, resolution: .best, formats: [.webm, .mp4, .av1, .avc1])
|
||||
|
||||
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case hls
|
||||
case stream
|
||||
case mp4
|
||||
case avc1
|
||||
case av1
|
||||
case webm
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .stream:
|
||||
return "Stream"
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
var streamFormat: Stream.Format? {
|
||||
switch self {
|
||||
case .hls:
|
||||
return nil
|
||||
case .stream:
|
||||
return nil
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .webm:
|
||||
return .webm
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .av1:
|
||||
return .av1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id = UUID().uuidString
|
||||
|
||||
var name: String?
|
||||
var backend: PlayerBackendType
|
||||
var resolution: ResolutionSetting
|
||||
var formats: [Format]
|
||||
|
||||
var description: String {
|
||||
if let name = name, !name.isEmpty { return name }
|
||||
return "\(backend.label) - \(resolution.description) - \(formats.map(\.description).joined(separator: ", "))"
|
||||
}
|
||||
|
||||
func isPreferred(_ stream: Stream) -> Bool {
|
||||
if formats.contains(.hls), stream.kind == .hls {
|
||||
return true
|
||||
}
|
||||
|
||||
let resolutionMatch = !stream.resolution.isNil && (resolution == .best || (resolution.value >= stream.resolution))
|
||||
|
||||
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
||||
return true
|
||||
}
|
||||
|
||||
let formatMatch = formats.compactMap(\.streamFormat).contains(stream.format)
|
||||
|
||||
return resolutionMatch && formatMatch
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityProfileBridge: Defaults.Bridge {
|
||||
static let formatsSeparator = ","
|
||||
|
||||
typealias Value = QualityProfile
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else { return nil }
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"name": value.name ?? "",
|
||||
"backend": value.backend.rawValue,
|
||||
"resolution": value.resolution.rawValue,
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard let object = object,
|
||||
let id = object["id"],
|
||||
let backend = PlayerBackendType(rawValue: object["backend"] ?? ""),
|
||||
let resolution = ResolutionSetting(rawValue: object["resolution"] ?? "")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"]
|
||||
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
||||
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
|
||||
}
|
||||
}
|
102
Model/QualityProfilesModel.swift
Normal file
102
Model/QualityProfilesModel.swift
Normal file
@@ -0,0 +1,102 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import Reachability
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct QualityProfilesModel {
|
||||
static let shared = QualityProfilesModel()
|
||||
|
||||
#if os(tvOS)
|
||||
var tvOSProfile: QualityProfile? {
|
||||
find(Defaults[.batteryNonCellularProfile])
|
||||
}
|
||||
#endif
|
||||
|
||||
func find(_ id: QualityProfile.ID) -> QualityProfile? {
|
||||
if id == "default" {
|
||||
return QualityProfile.defaultProfile
|
||||
} else if id == "highQuality" {
|
||||
return QualityProfile.highQualityProfile
|
||||
}
|
||||
|
||||
return Defaults[.qualityProfiles].first { $0.id == id }
|
||||
}
|
||||
|
||||
func add(_ qualityProfile: QualityProfile) {
|
||||
Defaults[.qualityProfiles].append(qualityProfile)
|
||||
}
|
||||
|
||||
func update(_ from: QualityProfile, _ to: QualityProfile) {
|
||||
if let index = Defaults[.qualityProfiles].firstIndex(where: { $0.id == from.id }) {
|
||||
Defaults[.qualityProfiles][index] = to
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ qualityProfile: QualityProfile) {
|
||||
if let index = Defaults[.qualityProfiles].firstIndex(where: { $0.id == qualityProfile.id }) {
|
||||
Defaults[.qualityProfiles].remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func applyToAll(_ qualityProfile: QualityProfile) {
|
||||
Defaults[.batteryCellularProfile] = qualityProfile.id
|
||||
Defaults[.batteryNonCellularProfile] = qualityProfile.id
|
||||
Defaults[.chargingCellularProfile] = qualityProfile.id
|
||||
Defaults[.chargingNonCellularProfile] = qualityProfile.id
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func findCurrentConnection() -> Reachability.Connection? {
|
||||
do {
|
||||
let reachability: Reachability = try Reachability()
|
||||
return reachability.connection
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var automaticProfile: QualityProfile? {
|
||||
var id: QualityProfile.ID?
|
||||
|
||||
#if os(iOS)
|
||||
UIDevice.current.isBatteryMonitoringEnabled = true
|
||||
let unplugged = UIDevice.current.batteryState == .unplugged
|
||||
let connection = findCurrentConnection()
|
||||
|
||||
if unplugged {
|
||||
switch connection {
|
||||
case .wifi:
|
||||
id = Defaults[.batteryNonCellularProfile]
|
||||
default:
|
||||
id = Defaults[.batteryCellularProfile]
|
||||
}
|
||||
} else {
|
||||
switch connection {
|
||||
case .wifi:
|
||||
id = Defaults[.chargingNonCellularProfile]
|
||||
default:
|
||||
id = Defaults[.chargingCellularProfile]
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if Power.hasInternalBattery {
|
||||
if Power.isConnectedToPower {
|
||||
id = Defaults[.chargingNonCellularProfile]
|
||||
} else {
|
||||
id = Defaults[.batteryNonCellularProfile]
|
||||
}
|
||||
} else {
|
||||
id = Defaults[.chargingNonCellularProfile]
|
||||
}
|
||||
#else
|
||||
id = Defaults[.chargingNonCellularProfile]
|
||||
#endif
|
||||
|
||||
guard let id = id else { return nil }
|
||||
|
||||
return find(id)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user