From ac9abaec5a6e64d8cfd9154de44f577af9b4af10 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 14 Aug 2022 19:06:22 +0200 Subject: [PATCH] Quality profiles --- Extensions/UIDevice+Cellular.swift | 28 ++ Model/Player/PlayerModel.swift | 33 +- Model/Player/PlayerQueue.swift | 21 +- Model/QualityProfile.swift | 112 ++++++ Model/QualityProfilesModel.swift | 102 ++++++ Shared/Defaults.swift | 43 ++- Shared/Player/Controls/ControlsOverlay.swift | 321 +++++++++++++----- Shared/Player/Controls/PlayerControls.swift | 122 ++++--- Shared/Player/StreamControl.swift | 22 +- Shared/Player/VideoPlayerView.swift | 149 +++++--- Shared/Settings/PlayerSettings.swift | 13 +- Shared/Settings/QualityProfileForm.swift | 312 +++++++++++++++++ Shared/Settings/QualitySettings.swift | 184 ++++++++++ Shared/Settings/SettingsView.swift | 31 +- Yattee.xcodeproj/project.pbxproj | 64 ++++ .../xcshareddata/swiftpm/Package.resolved | 9 + iOS/BridgingHeader.h | 1 + macOS/BridgingHeader.h | 1 + macOS/Power.swift | 38 +++ 19 files changed, 1372 insertions(+), 234 deletions(-) create mode 100644 Extensions/UIDevice+Cellular.swift create mode 100644 Model/QualityProfile.swift create mode 100644 Model/QualityProfilesModel.swift create mode 100644 Shared/Settings/QualityProfileForm.swift create mode 100644 Shared/Settings/QualitySettings.swift create mode 100644 macOS/Power.swift diff --git a/Extensions/UIDevice+Cellular.swift b/Extensions/UIDevice+Cellular.swift new file mode 100644 index 00000000..52e2245c --- /dev/null +++ b/Extensions/UIDevice+Cellular.swift @@ -0,0 +1,28 @@ +import Foundation +import UIKit + +extension UIDevice { + /// A Boolean value indicating whether the device has cellular data capabilities (true) or not (false). + var hasCellularCapabilites: Bool { + var addrs: UnsafeMutablePointer? + var cursor: UnsafeMutablePointer? + + defer { freeifaddrs(addrs) } + + guard getifaddrs(&addrs) == 0 else { return false } + cursor = addrs + + while cursor != nil { + guard + let utf8String = cursor?.pointee.ifa_name, + let name = NSString(utf8String: utf8String), + name == "pdp_ip0" + else { + cursor = cursor?.pointee.ifa_next + continue + } + return true + } + return false + } +} diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 39c0df4c..5e282f95 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -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 { diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index 1d8315d5..3dd9b26f 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -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 } } diff --git a/Model/QualityProfile.swift b/Model/QualityProfile.swift new file mode 100644 index 00000000..bf6e437b --- /dev/null +++ b/Model/QualityProfile.swift @@ -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) + } +} diff --git a/Model/QualityProfilesModel.swift b/Model/QualityProfilesModel.swift new file mode 100644 index 00000000..fa60e455 --- /dev/null +++ b/Model/QualityProfilesModel.swift @@ -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) + } +} diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index 800223b2..ce40925f 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -6,18 +6,6 @@ import SwiftUI #endif extension Defaults.Keys { - #if os(tvOS) - static let defaultForPauseOnHidingPlayer = true - #else - static let defaultForPauseOnHidingPlayer = false - #endif - - #if os(macOS) - static let defaultForPlayerDetailsPageButtonLabelStyle = PlayerDetailsPageButtonLabelStyle.iconAndText - #else - static let defaultForPlayerDetailsPageButtonLabelStyle = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText - #endif - static let instancesManifest = Key("instancesManifest", default: "") static let countryOfPublicInstances = Key("countryOfPublicInstances") @@ -50,7 +38,12 @@ extension Defaults.Keys { static let captionsLanguageCode = Key("captionsLanguageCode") static let activeBackend = Key("activeBackend", default: .mpv) - static let quality = Key("quality", default: .best) + static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: [QualityProfile.defaultProfile, QualityProfile.highQualityProfile]) + static let batteryCellularProfile = Key("batteryCellularProfile", default: QualityProfile.defaultProfile.id) + static let batteryNonCellularProfile = Key("batteryNonCellularProfile", default: QualityProfile.defaultProfile.id) + static let chargingCellularProfile = Key("chargingCellularProfile", default: QualityProfile.defaultProfile.id) + static let chargingNonCellularProfile = Key("chargingNonCellularProfile", default: QualityProfile.defaultProfile.id) + static let playerSidebar = Key("playerSidebar", default: PlayerSidebarSetting.defaultValue) static let playerInstanceID = Key("playerInstance") static let showKeywords = Key("showKeywords", default: false) @@ -58,12 +51,25 @@ extension Defaults.Keys { #if !os(tvOS) static let commentsPlacement = Key("commentsPlacement", default: .separate) #endif - static let pauseOnHidingPlayer = Key("pauseOnHidingPlayer", default: defaultForPauseOnHidingPlayer) + + #if os(tvOS) + static let pauseOnHidingPlayerDefault = true + #else + static let pauseOnHidingPlayerDefault = false + #endif + static let pauseOnHidingPlayer = Key("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault) + #if !os(macOS) static let pauseOnEnteringBackground = Key("pauseOnEnteringBackground", default: true) #endif static let closeLastItemOnPlaybackEnd = Key("closeLastItemOnPlaybackEnd", default: false) - static let closePlayerOnItemClose = Key("closePlayerOnItemClose", default: false) + + #if os(tvOS) + static let closePlayerOnItemCloseDefault = true + #else + static let closePlayerOnItemCloseDefault = false + #endif + static let closePlayerOnItemClose = Key("closePlayerOnItemClose", default: closePlayerOnItemCloseDefault) static let closePiPOnNavigation = Key("closePiPOnNavigation", default: false) static let closePiPOnOpeningPlayer = Key("closePiPOnOpeningPlayer", default: false) @@ -100,7 +106,12 @@ extension Defaults.Keys { static let showMPVPlaybackStats = Key("showMPVPlaybackStats", default: false) - static let playerDetailsPageButtonLabelStyle = Key("playerDetailsPageButtonLabelStyle", default: defaultForPlayerDetailsPageButtonLabelStyle) + #if os(macOS) + static let playerDetailsPageButtonLabelStyleDefault = PlayerDetailsPageButtonLabelStyle.iconAndText + #else + static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? PlayerDetailsPageButtonLabelStyle.iconOnly : .iconAndText + #endif + static let playerDetailsPageButtonLabelStyle = Key("playerDetailsPageButtonLabelStyle", default: playerDetailsPageButtonLabelStyleDefault) static let systemControlsCommands = Key("systemControlsCommands", default: .restartAndAdvanceToNext) static let mpvCacheSecs = Key("mpvCacheSecs", default: "20") diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index b409614b..77d6aa33 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -6,43 +6,125 @@ struct ControlsOverlay: View { @EnvironmentObject private var player @EnvironmentObject private var model + @State private var contentSize: CGSize = .zero + @Default(.showMPVPlaybackStats) private var showMPVPlaybackStats + @Default(.qualityProfiles) private var qualityProfiles + + #if os(tvOS) + enum Field: Hashable { + case qualityProfile + case stream + case increaseRate + case decreaseRate + case captions + } + + @FocusState private var focusedField: Field? + #endif var body: some View { ScrollView { - VStack(spacing: 6) { - HStack { - backendButtons - } - qualityButton + VStack { + Section(header: controlsHeader("Rate & Captions")) { + HStack(spacing: rateButtonsSpacing) { + decreaseRateButton + #if os(tvOS) + .focused($focusedField, equals: .decreaseRate) + #endif + rateButton + increaseRateButton + #if os(tvOS) + .focused($focusedField, equals: .increaseRate) + #endif + } - if player.activeBackend == .mpv { captionsButton + #if os(tvOS) + .focused($focusedField, equals: .captions) + #endif + .disabled(player.activeBackend != .mpv) + + #if os(iOS) + .foregroundColor(.white) + #endif } - HStack { - decreaseRateButton - rateButton - increaseRateButton + Section(header: controlsHeader("Quality Profile")) { + qualityProfileButton + #if os(tvOS) + .focused($focusedField, equals: .qualityProfile) + #endif + } + + Section(header: controlsHeader("Stream & Player")) { + qualityButton + #if os(tvOS) + .focused($focusedField, equals: .stream) + #endif + + #if !os(tvOS) + HStack { + backendButtons + } + #endif } - #if os(iOS) - .foregroundColor(.white) - #endif if player.activeBackend == .mpv, showMPVPlaybackStats { - mpvPlaybackStats + Section(header: controlsHeader("Statistics")) { + mpvPlaybackStats + } + #if os(tvOS) + .frame(width: 400) + #else + .frame(width: 240) + #endif } } + .overlay( + GeometryReader { geometry in + Color.clear.onAppear { + contentSize = geometry.size + } + } + ) + #if os(tvOS) + .padding(.horizontal, 40) + #endif } + .frame(maxHeight: overlayHeight) + #if os(tvOS) + .onAppear { + focusedField = .qualityProfile + } + #endif + } + + private var overlayHeight: Double { + #if os(tvOS) + contentSize.height + 50.0 + #else + contentSize.height + #endif + } + + private func controlsHeader(_ text: String) -> some View { + Text(text) + .font(.system(.caption)) + .foregroundColor(.secondary) } private var backendButtons: some View { ForEach(PlayerBackendType.allCases, id: \.self) { backend in backendButton(backend) + .frame(height: 40) + #if os(iOS) + .frame(maxWidth: 115) .modifier(ControlBackgroundModifier()) .clipShape(RoundedRectangle(cornerRadius: 4)) + #endif } } @@ -54,11 +136,48 @@ struct ControlsOverlay: View { } } label: { Text(backend.label) - .padding(6) .foregroundColor(player.activeBackend == backend ? .accentColor : .secondary) - .contentShape(Rectangle()) } - .buttonStyle(.plain) + #if os(macOS) + .buttonStyle(.bordered) + #else + .modifier(ControlBackgroundModifier()) + .clipShape(RoundedRectangle(cornerRadius: 4)) + #endif + } + + @ViewBuilder private var rateButton: some View { + #if os(macOS) + ratePicker + .labelsHidden() + .frame(maxWidth: 100) + #elseif os(iOS) + Menu { + ratePicker + } label: { + Text(player.rateLabel(player.currentRate)) + .foregroundColor(.primary) + .frame(width: 123) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(width: 123, height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + Text(player.rateLabel(player.currentRate)) + .frame(minWidth: 120) + #endif + } + + var ratePicker: some View { + Picker("Rate", selection: $player.currentRate) { + ForEach(PlayerModel.availableRates, id: \.self) { rate in + Text(player.rateLabel(rate)).tag(rate) + } + } + .transaction { t in t.animation = .none } } private var increaseRateButton: some View { @@ -72,12 +191,12 @@ struct ControlsOverlay: View { .foregroundColor(.primary) .labelStyle(.iconOnly) .padding(8) - .frame(height: 30) + .frame(width: 50, height: 40) .contentShape(Rectangle()) } #if os(macOS) .buttonStyle(.bordered) - #else + #elseif os(iOS) .modifier(ControlBackgroundModifier()) .clipShape(RoundedRectangle(cornerRadius: 4)) #endif @@ -96,18 +215,76 @@ struct ControlsOverlay: View { .foregroundColor(.primary) .labelStyle(.iconOnly) .padding(8) - .frame(height: 30) + .frame(width: 50, height: 40) .contentShape(Rectangle()) } #if os(macOS) .buttonStyle(.bordered) - #else + #elseif os(iOS) .modifier(ControlBackgroundModifier()) .clipShape(RoundedRectangle(cornerRadius: 4)) #endif .disabled(decreasedRate.isNil) } + private var rateButtonsSpacing: Double { + #if os(tvOS) + 10 + #else + 8 + #endif + } + + @ViewBuilder private var qualityProfileButton: some View { + #if os(macOS) + qualityProfilePicker + .labelsHidden() + .frame(maxWidth: 300) + #elseif os(iOS) + Menu { + qualityProfilePicker + } label: { + Text(player.qualityProfileSelection?.description ?? "Auto") + .frame(maxWidth: 240) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.primary) + .frame(maxWidth: 240) + .frame(height: 40) + .modifier(ControlBackgroundModifier()) + .mask(RoundedRectangle(cornerRadius: 3)) + #else + Button {} label: { + Text(player.qualityProfileSelection?.description ?? "Auto") + .lineLimit(1) + .frame(maxWidth: 320) + } + .contextMenu { + ForEach(qualityProfiles) { qualityProfile in + Button("Default") { player.qualityProfileSelection = nil } + Button { + player.qualityProfileSelection = qualityProfile + } label: { + Text(qualityProfile.description) + } + + Button("Cancel", role: .cancel) {} + } + } + #endif + } + + private var qualityProfilePicker: some View { + Picker("Quality Profile", selection: $player.qualityProfileSelection) { + Text("Automatic").tag(QualityProfile?.none) + ForEach(qualityProfiles) { qualityProfile in + Text(qualityProfile.description).tag(qualityProfile as QualityProfile?) + } + } + .transaction { t in t.animation = .none } + } + @ViewBuilder private var qualityButton: some View { #if os(macOS) StreamControl() @@ -116,23 +293,20 @@ struct ControlsOverlay: View { #elseif os(iOS) Menu { StreamControl() - .frame(width: 45, height: 30) - #if os(iOS) - .modifier(ControlBackgroundModifier()) - #endif - .mask(RoundedRectangle(cornerRadius: 3)) } label: { Text(player.streamSelection?.shortQuality ?? "loading") - .frame(width: 140, height: 30) + .frame(width: 140, height: 40) .foregroundColor(.primary) } .transaction { t in t.animation = .none } .buttonStyle(.plain) .foregroundColor(.primary) - .frame(width: 140, height: 30) + .frame(width: 240, height: 40) .modifier(ControlBackgroundModifier()) .mask(RoundedRectangle(cornerRadius: 3)) + #else + StreamControl() #endif } @@ -144,8 +318,6 @@ struct ControlsOverlay: View { #elseif os(iOS) Menu { captionsPicker - .frame(width: 140, height: 30) - .mask(RoundedRectangle(cornerRadius: 3)) } label: { HStack(spacing: 4) { Image(systemName: "text.bubble") @@ -154,14 +326,32 @@ struct ControlsOverlay: View { .foregroundColor(.primary) } } - .frame(width: 140, height: 30) + .frame(width: 240) + .frame(height: 40) } .transaction { t in t.animation = .none } .buttonStyle(.plain) .foregroundColor(.primary) - .frame(width: 140, height: 30) + .frame(width: 240) .modifier(ControlBackgroundModifier()) .mask(RoundedRectangle(cornerRadius: 3)) + #else + Button {} label: { + HStack(spacing: 8) { + Image(systemName: "text.bubble") + if let captions = captionsBinding.wrappedValue { + Text(captions.code) + } + } + .frame(maxWidth: 320) + } + .contextMenu { + ForEach(player.currentVideo?.captions ?? []) { caption in + Button(caption.description) { captionsBinding.wrappedValue = caption } + } + Button("Cancel", role: .cancel) {} + } + #endif } @@ -190,58 +380,29 @@ struct ControlsOverlay: View { ) } - @ViewBuilder private var rateButton: some View { - #if os(macOS) - ratePicker - .labelsHidden() - .frame(maxWidth: 100) - #elseif os(iOS) - Menu { - ratePicker - .frame(width: 100, height: 30) - .mask(RoundedRectangle(cornerRadius: 3)) - } label: { - Text(player.rateLabel(player.currentRate)) - .foregroundColor(.primary) - .frame(width: 80) - } - .transaction { t in t.animation = .none } - .buttonStyle(.plain) - .foregroundColor(.primary) - .frame(width: 100, height: 30) - .modifier(ControlBackgroundModifier()) - .mask(RoundedRectangle(cornerRadius: 3)) - #endif - } - - var ratePicker: some View { - Picker("Rate", selection: rateBinding) { - ForEach(PlayerModel.availableRates, id: \.self) { rate in - Text(player.rateLabel(rate)).tag(rate) - } - } - .transaction { t in t.animation = .none } - } - - private var rateBinding: Binding { - .init(get: { player.currentRate }, set: { rate in player.currentRate = rate }) - } - var mpvPlaybackStats: some View { - Group { - VStack(alignment: .leading, spacing: 6) { - Text("hw decoder: \(player.mpvBackend.hwDecoder)") - Text("dropped: \(player.mpvBackend.frameDropCount)") - Text("video: \(String(format: "%.2ffps", player.mpvBackend.outputFps))") - Text("buffering: \(String(format: "%.0f%%", networkState.bufferingState))") - Text("cache: \(String(format: "%.2fs", player.mpvBackend.cacheDuration))") - } - .mask(RoundedRectangle(cornerRadius: 3)) + VStack(alignment: .leading, spacing: 6) { + mpvPlaybackStatRow("Hardware decoder", player.mpvBackend.hwDecoder) + mpvPlaybackStatRow("Dropped frames", String(player.mpvBackend.frameDropCount)) + mpvPlaybackStatRow("Stream FPS", String(format: "%.2ffps", player.mpvBackend.outputFps)) + mpvPlaybackStatRow("Cached time", String(format: "%.2fs", player.mpvBackend.cacheDuration)) } - #if !os(tvOS) - .font(.system(size: 9)) + .padding(.top, 2) + #if os(tvOS) + .font(.system(size: 20)) + #else + .font(.system(size: 11)) #endif } + + func mpvPlaybackStatRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .foregroundColor(.secondary) + Spacer() + Text(value) + } + } } struct ControlsOverlay_Previews: PreviewProvider { diff --git a/Shared/Player/Controls/PlayerControls.swift b/Shared/Player/Controls/PlayerControls.swift index 2ba1cc60..27ee88f5 100644 --- a/Shared/Player/Controls/PlayerControls.swift +++ b/Shared/Player/Controls/PlayerControls.swift @@ -18,6 +18,8 @@ struct PlayerControls: View { case play case backward case forward + case settings + case close } @FocusState private var focusedField: Field? @@ -41,23 +43,25 @@ struct PlayerControls: View { if model.presentingControls && !model.presentingOverlays { VStack(spacing: 4) { - buttonsBar + #if !os(tvOS) + buttonsBar - HStack { - if !player.currentVideo.isNil, fullScreenLayout { - Button { - withAnimation(Self.animation) { - model.presentingDetailsOverlay = true + HStack { + if !player.currentVideo.isNil, fullScreenLayout { + Button { + withAnimation(Self.animation) { + model.presentingDetailsOverlay = true + } + } label: { + ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(maxWidth: 300, alignment: .leading) } - } label: { - ControlsBar(fullScreen: $model.presentingDetailsOverlay, presentingControls: false, detailsTogglePlayer: false, detailsToggleFullScreen: false) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .frame(maxWidth: 300, alignment: .leading) + .buttonStyle(.plain) } - .buttonStyle(.plain) + Spacer() } - Spacer() - } + #endif Spacer() @@ -86,28 +90,15 @@ struct PlayerControls: View { .frame(maxHeight: .infinity) } #if os(tvOS) - .onChange(of: model.presentingControls) { _ in - if model.presentingControls { - focusedField = .play - } - } - .onChange(of: focusedField) { _ in - model.resetTimer() + .onChange(of: model.presentingControls) { newValue in + if newValue { focusedField = .play } } + .onChange(of: focusedField) { _ in model.resetTimer() } #else - .background(PlayerGestures()) - .background(controlsBackground) + .background(PlayerGestures()) + .background(controlsBackground) #endif - if model.presentingControlsOverlay { - ControlsOverlay() - .frame(height: overlayHeight) - .padding() - .modifier(ControlBackgroundModifier()) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .transition(.opacity) - } - if model.presentingDetailsOverlay { VideoDetailsOverlay() .frame(maxWidth: detailsWidth, maxHeight: detailsHeight) @@ -117,7 +108,7 @@ struct PlayerControls: View { } if !model.presentingControls, - !model.presentingControls, + !model.presentingOverlays, let segment = player.lastSkipped { Button { @@ -140,18 +131,22 @@ struct PlayerControls: View { .transition(.opacity) } } - .onChange(of: player.controls.presentingOverlays) { newValue in + .onChange(of: model.presentingOverlays) { newValue in if newValue { player.backend.stopControlsUpdates() } else { + #if os(tvOS) + focusedField = .play + #endif player.backend.startControlsUpdates() } } - } - - var overlayHeight: Double { - guard let player = player, player.playerSize.height.isFinite else { return 0 } - return [0, [player.playerSize.height - 40, 140].min()!].max()! + #if os(tvOS) + .onReceive(model.reporter) { _ in + model.show() + model.resetTimer() + } + #endif } var detailsWidth: Double { @@ -226,24 +221,17 @@ struct PlayerControls: View { var buttonsBar: some View { HStack(spacing: 20) { - #if !os(tvOS) - fullscreenButton + fullscreenButton - #if os(iOS) - pipButton - lockOrientationButton - #endif - - Spacer() - - button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) { - withAnimation(Self.animation) { - model.presentingControlsOverlay.toggle() - } - } - - closeVideoButton + #if os(iOS) + pipButton + lockOrientationButton #endif + + Spacer() + + settingsButton + closeVideoButton } } @@ -259,10 +247,24 @@ struct PlayerControls: View { #endif } + private var settingsButton: some View { + button("settings", systemImage: "gearshape", active: model.presentingControlsOverlay) { + withAnimation(Self.animation) { + model.presentingControlsOverlay.toggle() + } + } + #if os(tvOS) + .focused($focusedField, equals: .settings) + #endif + } + private var closeVideoButton: some View { button("Close", systemImage: "xmark") { player.closeCurrentItem() } + #if os(tvOS) + .focused($focusedField, equals: .close) + #endif } private var musicModeButton: some View { @@ -308,6 +310,9 @@ struct PlayerControls: View { advanceToNextItemButton #if !os(tvOS) musicModeButton + #else + settingsButton + closeVideoButton #endif } .frame(maxWidth: .infinity, alignment: .trailing) @@ -388,7 +393,12 @@ struct PlayerControls: View { active: Bool = false, action: @escaping () -> Void = {} ) -> some View { - Button { + #if os(tvOS) + let useBackground = false + #else + let useBackground = background + #endif + return Button { action() model.resetTimer() } label: { @@ -408,7 +418,7 @@ struct PlayerControls: View { .buttonStyle(.plain) .foregroundColor(active ? Color("AppRedColor") : .primary) .frame(width: width ?? size, height: height ?? size) - .modifier(ControlBackgroundModifier(enabled: background)) + .modifier(ControlBackgroundModifier(enabled: useBackground)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) } diff --git a/Shared/Player/StreamControl.swift b/Shared/Player/StreamControl.swift index 41cd7712..6a2fbcb4 100644 --- a/Shared/Player/StreamControl.swift +++ b/Shared/Player/StreamControl.swift @@ -28,7 +28,7 @@ struct StreamControl: View { } .disabled(player.isLoadingAvailableStreams) - #else + #elseif os(iOS) Picker("", selection: $player.streamSelection) { ForEach(InstancesModel.all) { instance in let instanceStreams = availableStreamsForInstance(instance) @@ -46,16 +46,26 @@ struct StreamControl: View { .frame(minWidth: 110) .fixedSize(horizontal: true, vertical: true) .disabled(player.isLoadingAvailableStreams) + #else + Button {} label: { + Text(player.streamSelection?.shortQuality ?? "loading") + .frame(maxWidth: 320) + } + .contextMenu { + ForEach(player.availableStreamsSorted) { stream in + Button(stream.description) { player.streamSelection = stream } + } + + Button("Close", role: .cancel) {} + } #endif } .transaction { t in t.animation = .none } .onChange(of: player.streamSelection) { selection in - guard !selection.isNil else { - return - } - - player.upgradeToStream(selection!) + guard let selection = selection else { return } + player.upgradeToStream(selection) + player.controls.hideOverlays() } .frame(alignment: .trailing) } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 5eabd028..2cdb27ab 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -58,6 +58,42 @@ struct VideoPlayerView: View { @EnvironmentObject private var thumbnails var body: some View { + ZStack(alignment: overlayAlignment) { + videoPlayer + #if os(iOS) + .gesture(playerControls.presentingControlsOverlay ? videoPlayerCloseControlsOverlayGesture : nil) + #endif + + if playerControls.presentingControlsOverlay { + HStack { + ControlsOverlay() + #if os(tvOS) + .onExitCommand { + withAnimation(PlayerControls.animation) { + playerControls.hideOverlays() + } + } + .onPlayPauseCommand { + player.togglePlay() + } + #else + .frame(maxWidth: overlayWidth) + #endif + .padding() + .modifier(ControlBackgroundModifier()) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .transition(.opacity) + } + #if os(tvOS) + .clipShape(RoundedRectangle(cornerRadius: 10)) + #else + .frame(maxWidth: player.playerSize.width) + #endif + } + } + } + + var videoPlayer: some View { #if DEBUG // TODO: remove if #available(iOS 15.0, macOS 12.0, *) { @@ -66,8 +102,13 @@ struct VideoPlayerView: View { #endif #if os(macOS) - return HSplitView { - content + return GeometryReader { geometry in + HSplitView { + content + .onAppear { + playerSize = geometry.size + } + } } .alert(isPresented: $navigation.presentingAlertInVideoPlayer) { navigation.alert } .onOpenURL { @@ -124,7 +165,7 @@ struct VideoPlayerView: View { Orientation.lockOrientation(.allButUpsideDown) } stopOrientationUpdates() - player.controls.hideOverlays() + playerControls.hideOverlays() player.lockedOrientation = nil #endif @@ -139,7 +180,28 @@ struct VideoPlayerView: View { #endif } + var overlayWidth: Double { + guard playerSize.width.isFinite else { return 200 } + return [playerSize.width - 50, 250].min()! + } + + var overlayAlignment: Alignment { + #if os(tvOS) + return .bottomTrailing + #else + return .top + #endif + } + #if os(iOS) + var videoPlayerCloseControlsOverlayGesture: some Gesture { + TapGesture().onEnded { + withAnimation(PlayerControls.animation) { + playerControls.hideOverlays() + } + } + } + var playerOffset: Double { dragGestureState ? dragGestureOffset.height : viewDragOffset } @@ -153,9 +215,14 @@ struct VideoPlayerView: View { } var playerEdgesIgnoringSafeArea: Edge.Set { + if let orientation = player.lockedOrientation, orientation.contains(.portrait) { + return [] + } + if fullScreenLayout, UIDevice.current.orientation.isLandscape { return [.vertical] } + return [] } #endif @@ -170,33 +237,6 @@ struct VideoPlayerView: View { tvControls } .ignoresSafeArea() - .onMoveCommand { direction in - if direction == .up || direction == .down { - playerControls.show() - } - - playerControls.resetTimer() - - guard !playerControls.presentingControls else { return } - - if direction == .left { - player.backend.seek(relative: .secondsInDefaultTimescale(-10)) - } - if direction == .right { - player.backend.seek(relative: .secondsInDefaultTimescale(10)) - } - } - .onPlayPauseCommand { - player.togglePlay() - } - - .onExitCommand { - if playerControls.presentingControls { - playerControls.hide() - } else { - player.hide() - } - } #else GeometryReader { geometry in PlayerBackendView() @@ -259,6 +299,41 @@ struct VideoPlayerView: View { #if os(macOS) .frame(minWidth: 650) #endif + #if os(tvOS) + .onMoveCommand { direction in + if direction == .up { + playerControls.show() + } else if direction == .down, !playerControls.presentingControlsOverlay { + withAnimation(PlayerControls.animation) { + playerControls.presentingControlsOverlay = true + } + } + + playerControls.resetTimer() + + guard !playerControls.presentingControls else { return } + + if direction == .left { + player.backend.seek(relative: .secondsInDefaultTimescale(-10)) + } + if direction == .right { + player.backend.seek(relative: .secondsInDefaultTimescale(10)) + } + } + .onPlayPauseCommand { + player.togglePlay() + } + .onExitCommand { + if playerControls.presentingOverlays { + playerControls.hideOverlays() + } + if playerControls.presentingControls { + playerControls.hide() + } else { + player.hide() + } + } + #endif if !fullScreenLayout { #if os(iOS) if sidebarQueue { @@ -277,7 +352,7 @@ struct VideoPlayerView: View { } } .onChange(of: fullScreenLayout) { newValue in - if !newValue { playerControls.presentingDetailsOverlay = false } + if !newValue { playerControls.hideOverlays() } } #if os(iOS) .statusBar(hidden: fullScreenLayout) @@ -346,8 +421,8 @@ struct VideoPlayerView: View { guard player.presentingPlayer, !playerControls.presentingControlsOverlay else { return } - if player.controls.presentingControls { - player.controls.presentingControls = false + if playerControls.presentingControls { + playerControls.presentingControls = false } let drag = value.translation.height @@ -401,7 +476,7 @@ struct VideoPlayerView: View { !player.playingInPictureInPicture { DispatchQueue.main.async { - player.controls.presentingControls = false + playerControls.presentingControls = false player.enterFullScreen(showControls: false) } @@ -435,7 +510,7 @@ struct VideoPlayerView: View { } if orientation.isLandscape { - player.controls.presentingControls = false + playerControls.presentingControls = false player.enterFullScreen(showControls: false) Orientation.lockOrientation(OrientationTracker.shared.currentInterfaceOrientationMask, andRotateTo: orientation) } else { @@ -455,10 +530,6 @@ struct VideoPlayerView: View { #if os(tvOS) var tvControls: some View { TVControls(model: playerControls, player: player, thumbnails: thumbnails) - .onReceive(playerControls.reporter) { _ in - playerControls.show() - playerControls.resetTimer() - } } #endif } diff --git a/Shared/Settings/PlayerSettings.swift b/Shared/Settings/PlayerSettings.swift index 8533021b..cbad9270 100644 --- a/Shared/Settings/PlayerSettings.swift +++ b/Shared/Settings/PlayerSettings.swift @@ -4,7 +4,6 @@ import SwiftUI struct PlayerSettings: View { @Default(.instances) private var instances @Default(.playerInstanceID) private var playerInstanceID - @Default(.quality) private var quality @Default(.playerSidebar) private var playerSidebar @Default(.showHistoryInPlayer) private var showHistory @@ -59,7 +58,6 @@ struct PlayerSettings: View { Group { Section(header: SettingsHeader(text: "Playback")) { sourcePicker - qualityPicker pauseOnHidingPlayerToggle #if !os(macOS) pauseOnEnteringBackgroundToogle @@ -107,7 +105,7 @@ struct PlayerSettings: View { private var sourcePicker: some View { Picker("Source", selection: $playerInstanceID) { - Text("Best available stream").tag(String?.none) + Text("Account Instance").tag(String?.none) ForEach(instances) { instance in Text(instance.description).tag(Optional(instance.id)) @@ -135,15 +133,6 @@ struct PlayerSettings: View { .modifier(SettingsPickerModifier()) } - private var qualityPicker: some View { - Picker("Quality", selection: $quality) { - ForEach(ResolutionSetting.allCases, id: \.self) { resolution in - Text(resolution.description).tag(resolution) - } - } - .modifier(SettingsPickerModifier()) - } - private var sidebarPicker: some View { Picker("Sidebar", selection: $playerSidebar) { #if os(macOS) diff --git a/Shared/Settings/QualityProfileForm.swift b/Shared/Settings/QualityProfileForm.swift new file mode 100644 index 00000000..8f2e6083 --- /dev/null +++ b/Shared/Settings/QualityProfileForm.swift @@ -0,0 +1,312 @@ +import SwiftUI + +struct QualityProfileForm: View { + var qualityProfileID: QualityProfile.ID! + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.presentationMode) private var presentationMode + @Environment(\.navigationStyle) private var navigationStyle + + @State private var valid = false + + @State private var name = "" + @State private var backend = PlayerBackendType.mpv + @State private var resolution = ResolutionSetting.best + @State private var formats = [QualityProfile.Format]() + + var qualityProfile: QualityProfile! { + if let id = qualityProfileID { + return QualityProfilesModel.shared.find(id) + } + + return nil + } + + var body: some View { + ScrollView { + VStack { + Group { + header + #if os(iOS) + NavigationView { + EmptyView() + + form + .navigationBarHidden(true) + .navigationBarTitle(Text("Back")) + .edgesIgnoringSafeArea([.top, .bottom]) + } + .navigationViewStyle(.stack) + #else + form + #endif + footer + } + .frame(maxWidth: 1000) + } + #if os(tvOS) + .padding(20) + #endif + } + .onAppear(perform: initializeForm) + .onChange(of: backend, perform: backendChanged) + .onChange(of: formats) { _ in validate() } + #if os(iOS) + .padding(.vertical) + #elseif os(tvOS) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .background(Color.background(scheme: colorScheme)) + #else + .frame(width: 400, height: 400) + .padding(.vertical, 10) + #endif + } + + var header: some View { + HStack(alignment: .center) { + Text(editing ? "Edit Quality Profile" : "Add Quality Profile") + .font(.title2.bold()) + + Spacer() + + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + #if !os(tvOS) + .keyboardShortcut(.cancelAction) + #endif + } + .padding(.horizontal) + } + + var form: some View { + #if !os(tvOS) + Form { + formFields + #if os(macOS) + .padding(.horizontal) + #endif + } + #else + formFields + #endif + } + + var formFields: some View { + Group { + Section { + HStack { + nameHeader + TextField("Name", text: $name, onCommit: validate) + .labelsHidden() + } + #if os(tvOS) + Section(header: Text("Resolution")) { + qualityButton + } + #else + backendPicker + qualityPicker + #endif + } + Section(header: Text("Preferred Formats"), footer: formatsFooter) { + formatsPicker + } + } + #if os(tvOS) + .frame(maxWidth: .infinity, alignment: .leading) + #endif + } + + @ViewBuilder var nameHeader: some View { + #if os(macOS) + Text("Name") + #else + EmptyView() + #endif + } + + var formatsFooter: some View { + Text("Formats will be selected in order as listed.\nHLS is an adaptive format (resolution setting does not apply).") + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + var qualityPicker: some View { + Picker("Resolution", selection: $resolution) { + ForEach(availableResolutions, id: \.self) { resolution in + Text(resolution.description).tag(resolution) + } + } + .modifier(SettingsPickerModifier()) + } + + #if os(tvOS) + var qualityButton: some View { + Button(resolution.description) { + resolution = resolution.next() + } + .contextMenu { + ForEach(availableResolutions, id: \.self) { resolution in + Button(resolution.description) { + self.resolution = resolution + } + } + } + } + #endif + + var availableResolutions: [ResolutionSetting] { + ResolutionSetting.allCases.filter { !isResolutionDisabled($0) } + } + + var backendPicker: some View { + Picker("Backend", selection: $backend) { + ForEach(PlayerBackendType.allCases, id: \.self) { backend in + Text(backend.label).tag(backend) + } + } + .modifier(SettingsPickerModifier()) + } + + @ViewBuilder var formatsPicker: some View { + #if os(macOS) + let list = ForEach(QualityProfile.Format.allCases, id: \.self) { format in + MultiselectRow( + title: format.description, + selected: isFormatSelected(format), + disabled: isFormatDisabled(format) + ) { value in + toggleFormat(format, value: value) + } + } + + Group { + if #available(macOS 12.0, *) { + list + .listStyle(.inset(alternatesRowBackgrounds: true)) + } else { + list + .listStyle(.inset) + } + } + Spacer() + #else + ForEach(QualityProfile.Format.allCases, id: \.self) { format in + MultiselectRow( + title: format.description, + selected: isFormatSelected(format), + disabled: isFormatDisabled(format) + ) { value in + toggleFormat(format, value: value) + } + } + #endif + } + + func isFormatSelected(_ format: QualityProfile.Format) -> Bool { + (editing && formats.isEmpty ? qualityProfile.formats : formats).contains(format) + } + + func toggleFormat(_ format: QualityProfile.Format, value: Bool) { + if let index = formats.firstIndex(where: { $0 == format }), !value { + formats.remove(at: index) + } else if value { + formats.append(format) + } + } + + var footer: some View { + HStack { + Spacer() + Button("Save", action: submitForm) + .disabled(!valid) + #if !os(tvOS) + .keyboardShortcut(.defaultAction) + #endif + } + .frame(minHeight: 35) + #if os(tvOS) + .padding(.top, 30) + #endif + .padding(.horizontal) + } + + var editing: Bool { + !qualityProfile.isNil + } + + func isFormatDisabled(_ format: QualityProfile.Format) -> Bool { + guard backend == .appleAVPlayer else { return false } + + let avPlayerFormats = [QualityProfile.Format.hls, .stream] + + return !avPlayerFormats.contains(format) + } + + func isResolutionDisabled(_ resolution: ResolutionSetting) -> Bool { + guard backend == .appleAVPlayer else { return false } + + return resolution != .best && resolution.value.height > 720 + } + + func initializeForm() { + guard editing else { + validate() + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.name = qualityProfile.name ?? "" + self.backend = qualityProfile.backend + self.resolution = qualityProfile.resolution + self.formats = .init(qualityProfile.formats) + } + + validate() + } + + func backendChanged(_: PlayerBackendType) { + formats.filter { isFormatDisabled($0) }.forEach { format in + if let index = formats.firstIndex(where: { $0 == format }) { + formats.remove(at: index) + } + } + + if let newResolution = availableResolutions.first { + resolution = newResolution + } + } + + func validate() { + valid = !formats.isEmpty + } + + func submitForm() { + guard valid else { return } + + formats = formats.unique() + + let formProfile = QualityProfile( + id: qualityProfile?.id ?? UUID().uuidString, + name: name, + backend: backend, + resolution: resolution, + formats: Array(formats) + ) + + if editing { + QualityProfilesModel.shared.update(qualityProfile, formProfile) + } else { + QualityProfilesModel.shared.add(formProfile) + } + + presentationMode.wrappedValue.dismiss() + } +} + +struct QualityProfileForm_Previews: PreviewProvider { + static var previews: some View { + QualityProfileForm(qualityProfileID: QualityProfile.defaultProfile.id) + } +} diff --git a/Shared/Settings/QualitySettings.swift b/Shared/Settings/QualitySettings.swift new file mode 100644 index 00000000..0d779ea9 --- /dev/null +++ b/Shared/Settings/QualitySettings.swift @@ -0,0 +1,184 @@ +import Defaults +import SwiftUI + +struct QualitySettings: View { + @State private var presentingProfileForm = false + @State private var editedProfile: QualityProfile? + + @Default(.qualityProfiles) private var qualityProfiles + + @Default(.batteryCellularProfile) private var batteryCellularProfile + @Default(.batteryNonCellularProfile) private var batteryNonCellularProfile + @Default(.chargingCellularProfile) private var chargingCellularProfile + @Default(.chargingNonCellularProfile) private var chargingNonCellularProfile + + var body: some View { + VStack { + #if os(macOS) + sections + + Spacer() + #else + List { + sections + } + #endif + } + .sheet(isPresented: $presentingProfileForm) { + QualityProfileForm(qualityProfileID: editedProfile?.id) + } + #if os(tvOS) + .frame(maxWidth: 1000) + #elseif os(iOS) + .listStyle(.insetGrouped) + #endif + .navigationTitle("Quality") + } + + var sections: some View { + Group { + Group { + #if os(tvOS) + Section(header: Text("Default Profile")) { + Text("\(QualityProfilesModel.shared.tvOSProfile?.description ?? "None")") + } + #elseif os(iOS) + if UIDevice.current.hasCellularCapabilites { + Section(header: Text("Battery")) { + Picker("Wi-Fi", selection: $batteryNonCellularProfile) { profilePickerOptions } + Picker("Cellular", selection: $batteryCellularProfile) { profilePickerOptions } + } + Section(header: Text("Charging")) { + Picker("Wi-Fi", selection: $chargingNonCellularProfile) { profilePickerOptions } + Picker("Cellular", selection: $chargingCellularProfile) { profilePickerOptions } + } + } else { + nonCellularBatteryDevicesProfilesPickers + } + #else + if Power.hasInternalBattery { + nonCellularBatteryDevicesProfilesPickers + } else { + Picker("Default", selection: $chargingNonCellularProfile) { profilePickerOptions } + } + #endif + } + .disabled(qualityProfiles.isEmpty) + Section(header: SettingsHeader(text: "Profiles"), footer: profilesFooter) { + profilesList + + Button { + editedProfile = nil + presentingProfileForm = true + } label: { + Label("Add profile...", systemImage: "plus") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder var nonCellularBatteryDevicesProfilesPickers: some View { + Picker("Battery", selection: $batteryNonCellularProfile) { profilePickerOptions } + Picker("Charging", selection: $chargingNonCellularProfile) { profilePickerOptions } + } + + @ViewBuilder func profileControl(_ qualityProfile: QualityProfile) -> some View { + #if os(tvOS) + Button { + QualityProfilesModel.shared.applyToAll(qualityProfile) + } label: { + Text(qualityProfile.description) + } + #else + Text(qualityProfile.description) + #endif + } + + var profilePickerOptions: some View { + ForEach(qualityProfiles) { qualityProfile in + Text(qualityProfile.description).tag(qualityProfile.id) + } + } + + var profilesFooter: some View { + #if os(tvOS) + Text("You can switch between profiles in playback settings controls.") + #else + Text("You can use automatic profile selection based on current device status or switch it in video playback settings controls.") + .foregroundColor(.secondary) + #endif + } + + @ViewBuilder var profilesList: some View { + let list = ForEach(qualityProfiles) { qualityProfile in + profileControl(qualityProfile) + .contextMenu { + Button { + QualityProfilesModel.shared.applyToAll(qualityProfile) + } label: { + #if os(tvOS) + Text("Make default") + #elseif os(iOS) + Label("Apply to all", systemImage: "wand.and.stars") + #else + if Power.hasInternalBattery { + Text("Apply to all") + } else { + Text("Make default") + } + #endif + } + Button { + editedProfile = qualityProfile + presentingProfileForm = true + } label: { + Label("Edit...", systemImage: "pencil") + } + + Button { + QualityProfilesModel.shared.remove(qualityProfile) + } label: { + Label("Remove", systemImage: "trash") + } + + #if os(tvOS) + Button("Cancel", role: .cancel) {} + #endif + } + } + + if #available(macOS 12.0, *) { + #if os(macOS) + List { + list + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + #else + list + #endif + } else { + #if os(macOS) + List { + list + } + #else + list + #endif + } + } +} + +struct QualitySettings_Previews: PreviewProvider { + static var previews: some View { + #if os(macOS) + QualitySettings() + #else + NavigationView { + EmptyView() + QualitySettings() + } + .navigationViewStyle(.stack) + #endif + } +} diff --git a/Shared/Settings/SettingsView.swift b/Shared/Settings/SettingsView.swift index e460c37c..5ef99a53 100644 --- a/Shared/Settings/SettingsView.swift +++ b/Shared/Settings/SettingsView.swift @@ -1,14 +1,13 @@ import Defaults import Foundation import SwiftUI - struct SettingsView: View { static let matrixURL = URL(string: "https://tinyurl.com/matrix-yattee")! static let discordURL = URL(string: "https://yattee.stream/discord")! #if os(macOS) private enum Tabs: Hashable { - case locations, browsing, player, history, sponsorBlock, advanced, help + case locations, browsing, player, quality, history, sponsorBlock, advanced, help } @State private var selection = Tabs.locations @@ -59,6 +58,14 @@ struct SettingsView: View { } .tag(Tabs.player) + Form { + QualitySettings() + } + .tabItem { + Label("Quality", systemImage: "4k.tv") + } + .tag(Tabs.quality) + Form { HistorySettings() } @@ -92,18 +99,14 @@ struct SettingsView: View { .tag(Tabs.help) } .padding(20) - .frame(width: 480, height: windowHeight) + .frame(width: 520, height: windowHeight) #else - Group { + NavigationView { + settingsList #if os(tvOS) - settingsList - #else - NavigationView { - settingsList - } + .navigationBarHidden(true) #endif } - #endif } @@ -142,6 +145,12 @@ struct SettingsView: View { Label("Player", systemImage: "play.rectangle") } + NavigationLink { + QualitySettings() + } label: { + Label("Quality", systemImage: "4k.tv") + } + NavigationLink { HistorySettings() } label: { @@ -219,6 +228,8 @@ struct SettingsView: View { return 390 case .player: return 420 + case .quality: + return 400 case .history: return 480 case .sponsorBlock: diff --git a/Yattee.xcodeproj/project.pbxproj b/Yattee.xcodeproj/project.pbxproj index cd933394..da92dfa0 100644 --- a/Yattee.xcodeproj/project.pbxproj +++ b/Yattee.xcodeproj/project.pbxproj @@ -340,6 +340,15 @@ 375E45F627B1976B00BA7902 /* MPVOGLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375E45F427B1976B00BA7902 /* MPVOGLView.swift */; }; 375E45F827B1AC4700BA7902 /* PlayerControlsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375E45F727B1AC4700BA7902 /* PlayerControlsModel.swift */; }; 375E45F927B1AC4700BA7902 /* PlayerControlsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375E45F727B1AC4700BA7902 /* PlayerControlsModel.swift */; }; + 375EC959289EEB8200751258 /* QualityProfileForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC958289EEB8200751258 /* QualityProfileForm.swift */; }; + 375EC95A289EEB8200751258 /* QualityProfileForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC958289EEB8200751258 /* QualityProfileForm.swift */; }; + 375EC95B289EEB8200751258 /* QualityProfileForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC958289EEB8200751258 /* QualityProfileForm.swift */; }; + 375EC95D289EEEE000751258 /* QualityProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC95C289EEEE000751258 /* QualityProfile.swift */; }; + 375EC95E289EEEE000751258 /* QualityProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC95C289EEEE000751258 /* QualityProfile.swift */; }; + 375EC95F289EEEE000751258 /* QualityProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC95C289EEEE000751258 /* QualityProfile.swift */; }; + 375EC96A289F232600751258 /* QualityProfilesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC969289F232600751258 /* QualityProfilesModel.swift */; }; + 375EC96B289F232600751258 /* QualityProfilesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC969289F232600751258 /* QualityProfilesModel.swift */; }; + 375EC96C289F232600751258 /* QualityProfilesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC969289F232600751258 /* QualityProfilesModel.swift */; }; 375EC972289F2ABF00751258 /* MultiselectRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC971289F2ABF00751258 /* MultiselectRow.swift */; }; 375EC973289F2ABF00751258 /* MultiselectRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC971289F2ABF00751258 /* MultiselectRow.swift */; }; 375EC974289F2ABF00751258 /* MultiselectRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375EC971289F2ABF00751258 /* MultiselectRow.swift */; }; @@ -368,6 +377,7 @@ 3765917E27237D2A009F956E /* PINCache in Frameworks */ = {isa = PBXBuildFile; productRef = 3765917D27237D2A009F956E /* PINCache */; }; 37666BAA27023AF000F869E5 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37666BA927023AF000F869E5 /* AccountSelectionView.swift */; }; 3766AFD2273DA97D00686348 /* Int+FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BA796D26DC412E002A0235 /* Int+FormatTests.swift */; }; + 3769537928A877C4005D87C3 /* StreamControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3795593527B08538007FF8F4 /* StreamControl.swift */; }; 3769C02E2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */; }; 3769C02F2779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */; }; 3769C0302779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3769C02D2779F18600DDB3EA /* PlaceholderProgressView.swift */; }; @@ -518,6 +528,9 @@ 379775942689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379775952689365600DD52A8 /* Array+Next.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379775922689365600DD52A8 /* Array+Next.swift */; }; 379B0253287A1CDF001015B5 /* OrientationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379B0252287A1CDF001015B5 /* OrientationTracker.swift */; }; + 379F141F289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; }; + 379F1420289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; }; + 379F1421289ECE7F00DE48B5 /* QualitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */; }; 37A3B15A27255E7F000FB5EE /* SafariWebExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A3B15927255E7F000FB5EE /* SafariWebExtensionHandler.swift */; }; 37A3B15F27255E7F000FB5EE /* images in Resources */ = {isa = PBXBuildFile; fileRef = 37A3B15E27255E7F000FB5EE /* images */; }; 37A3B16127255E7F000FB5EE /* manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 37A3B16027255E7F000FB5EE /* manifest.json */; }; @@ -746,6 +759,7 @@ 37EBD8CB27AF26C200F1C24B /* MPVBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EBD8C927AF26C200F1C24B /* MPVBackend.swift */; }; 37EBD8CC27AF26C200F1C24B /* MPVBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EBD8C927AF26C200F1C24B /* MPVBackend.swift */; }; 37ECED56289FE166002BC2C9 /* SafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37ECED55289FE166002BC2C9 /* SafeArea.swift */; }; + 37EE6DC528A305AD00BFD632 /* Reachability in Frameworks */ = {isa = PBXBuildFile; productRef = 37EE6DC428A305AD00BFD632 /* Reachability */; }; 37EF5C222739D37B00B03725 /* MenuModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF5C212739D37B00B03725 /* MenuModel.swift */; }; 37EF5C232739D37B00B03725 /* MenuModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF5C212739D37B00B03725 /* MenuModel.swift */; }; 37EF5C242739D37B00B03725 /* MenuModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EF5C212739D37B00B03725 /* MenuModel.swift */; }; @@ -781,6 +795,9 @@ 37F64FE426FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; }; 37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; }; 37F64FE626FE70A60081B69E /* RedrawOnModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */; }; + 37F7AB4D28A9361F00FB46B5 /* UIDevice+Cellular.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F7AB4C28A9361F00FB46B5 /* UIDevice+Cellular.swift */; }; + 37F7AB5228A94EB900FB46B5 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37F7AB4E28A94E0600FB46B5 /* IOKit.framework */; }; + 37F7AB5528A951B200FB46B5 /* Power.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F7AB5428A951B200FB46B5 /* Power.swift */; }; 37F7D82C289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F7D82B289EB05F00E2B3D0 /* SettingsPickerModifier.swift */; }; 37F7D82D289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F7D82B289EB05F00E2B3D0 /* SettingsPickerModifier.swift */; }; 37F7D82E289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F7D82B289EB05F00E2B3D0 /* SettingsPickerModifier.swift */; }; @@ -1041,6 +1058,9 @@ 375DFB5726F9DA010013F468 /* InstancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModel.swift; sourceTree = ""; }; 375E45F427B1976B00BA7902 /* MPVOGLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVOGLView.swift; sourceTree = ""; }; 375E45F727B1AC4700BA7902 /* PlayerControlsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControlsModel.swift; sourceTree = ""; }; + 375EC958289EEB8200751258 /* QualityProfileForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityProfileForm.swift; sourceTree = ""; }; + 375EC95C289EEEE000751258 /* QualityProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityProfile.swift; sourceTree = ""; }; + 375EC969289F232600751258 /* QualityProfilesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualityProfilesModel.swift; sourceTree = ""; }; 375EC971289F2ABF00751258 /* MultiselectRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiselectRow.swift; sourceTree = ""; }; 375F740F289DC35A00747050 /* PlayerBackendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerBackendView.swift; sourceTree = ""; }; 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; @@ -1093,6 +1113,7 @@ 379775922689365600DD52A8 /* Array+Next.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Next.swift"; sourceTree = ""; }; 37992DC726CC50BC003D4C27 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 379B0252287A1CDF001015B5 /* OrientationTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrientationTracker.swift; sourceTree = ""; }; + 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QualitySettings.swift; sourceTree = ""; }; 37A3B15727255E7F000FB5EE /* Open in Yattee - macOS.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Open in Yattee - macOS.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 37A3B15927255E7F000FB5EE /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; 37A3B15E27255E7F000FB5EE /* images */ = {isa = PBXFileReference; lastKnownFileType = folder; path = images; sourceTree = ""; }; @@ -1217,6 +1238,9 @@ 37F4AD2528613B81004D0F66 /* Color+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Debug.swift"; sourceTree = ""; }; 37F4AE7126828F0900BD60EA /* VerticalCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCells.swift; sourceTree = ""; }; 37F64FE326FE70A60081B69E /* RedrawOnModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedrawOnModifier.swift; sourceTree = ""; }; + 37F7AB4C28A9361F00FB46B5 /* UIDevice+Cellular.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Cellular.swift"; sourceTree = ""; }; + 37F7AB4E28A94E0600FB46B5 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; + 37F7AB5428A951B200FB46B5 /* Power.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Power.swift; sourceTree = ""; }; 37F7D82B289EB05F00E2B3D0 /* SettingsPickerModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPickerModifier.swift; sourceTree = ""; }; 37F9619A27BD89E000058149 /* TapRecognizerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapRecognizerViewModifier.swift; sourceTree = ""; }; 37F9619E27BD90BB00058149 /* PlayerBackendType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerBackendType.swift; sourceTree = ""; }; @@ -1271,6 +1295,7 @@ 372AA410286D067B0000B1DC /* Repeat in Frameworks */, 37C2212327ADA3F200305B41 /* libiconv.tbd in Frameworks */, 37C2212127ADA3A600305B41 /* libbz2.tbd in Frameworks */, + 37EE6DC528A305AD00BFD632 /* Reachability in Frameworks */, 37C2211F27ADA3A200305B41 /* libz.tbd in Frameworks */, 37FB284B2722099E00A57617 /* SDWebImageSwiftUI in Frameworks */, 3736A210286BB72300C9E5EE /* libavcodec.xcframework in Frameworks */, @@ -1303,6 +1328,7 @@ 372AA414286D06A10000B1DC /* Repeat in Frameworks */, 370F4FD227CC16CB001B35DC /* libavformat.59.16.100.dylib in Frameworks */, 370F4FD327CC16CB001B35DC /* libass.9.dylib in Frameworks */, + 37F7AB5228A94EB900FB46B5 /* IOKit.framework in Frameworks */, 370F4FDF27CC16CB001B35DC /* libxcb-shape.0.0.0.dylib in Frameworks */, 370F4FE127CC16CB001B35DC /* libuchardet.0.0.7.dylib in Frameworks */, 370F4FDB27CC16CB001B35DC /* libswscale.6.4.100.dylib in Frameworks */, @@ -1688,6 +1714,8 @@ 374C053427242D9F009BDDBE /* SponsorBlockSettings.swift */, 376BE50627347B57009AD608 /* SettingsHeader.swift */, 37B044B626F7AB9000E1419D /* SettingsView.swift */, + 379F141E289ECE7F00DE48B5 /* QualitySettings.swift */, + 375EC958289EEB8200751258 /* QualityProfileForm.swift */, 375EC971289F2ABF00751258 /* MultiselectRow.swift */, ); path = Settings; @@ -1762,6 +1790,7 @@ 377FC7D1267A080300A6BBAF /* Frameworks */ = { isa = PBXGroup; children = ( + 37F7AB4E28A94E0600FB46B5 /* IOKit.framework */, 37C2212A27ADA43700305B41 /* VideoToolbox.framework */, 37C2212827ADA41400305B41 /* CoreMedia.framework */, 3772003227E8EEA100CB2475 /* AudioToolbox.framework */, @@ -1854,6 +1883,7 @@ 37FD43DB270470B70073EE42 /* InstancesSettings.swift */, 3751BA7D27E63F1D007B1A60 /* MPVOGLView.swift */, 374108D0272B11B2006C5CC8 /* PictureInPictureDelegate.swift */, + 37F7AB5428A951B200FB46B5 /* Power.swift */, 37E04C0E275940FB00172673 /* VerticalScrollingFix.swift */, 3751BA7F27E64244007B1A60 /* VideoLayer.swift */, 37737785276F9858000521C1 /* Windows.swift */, @@ -1879,6 +1909,7 @@ 377ABC47286E5887009C986F /* Sequence+Unique.swift */, 3782B9512755667600990149 /* String+Format.swift */, 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */, + 37F7AB4C28A9361F00FB46B5 /* UIDevice+Cellular.swift */, 370B79CB286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift */, 3743CA51270F284F00E4D32B /* View+Borders.swift */, ); @@ -2021,6 +2052,8 @@ 37130A5E277657300033018A /* PersistenceController.swift */, 376578882685471400D4EA09 /* Playlist.swift */, 37BA794226DBA973002A0235 /* PlaylistsModel.swift */, + 375EC95C289EEEE000751258 /* QualityProfile.swift */, + 375EC969289F232600751258 /* QualityProfilesModel.swift */, 37C194C626F6A9C8005D3B96 /* RecentsModel.swift */, 37EAD86E267B9ED100D9E01B /* Segment.swift */, 37F0F4E9286F397E00C06C2E /* SettingsModel.swift */, @@ -2233,6 +2266,7 @@ 37CF8B8328535E4F00B71E37 /* SDWebImage */, 37A5DBC3285DFF5400CA4DD1 /* SwiftUIPager */, 372AA40F286D067B0000B1DC /* Repeat */, + 37EE6DC428A305AD00BFD632 /* Reachability */, ); productName = "Yattee (iOS)"; productReference = 37D4B0C92671614900C925CA /* Yattee.app */; @@ -2442,6 +2476,7 @@ 37CF8B8228535E4F00B71E37 /* XCRemoteSwiftPackageReference "SDWebImage" */, 37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */, 372AA40E286D067B0000B1DC /* XCRemoteSwiftPackageReference "Repeat" */, + 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */, ); productRefGroup = 37D4B0CA2671614900C925CA /* Products */; projectDirPath = ""; @@ -2710,6 +2745,7 @@ 37169AA62729E2CC0011DE61 /* AccountsBridge.swift in Sources */, 37C7A1DA267CACF50010EAD6 /* TrendingCountry.swift in Sources */, 37E80F40287B472300561799 /* ScrollContentBackground+Backport.swift in Sources */, + 375EC959289EEB8200751258 /* QualityProfileForm.swift in Sources */, 3727B74A27872A920021C15E /* VisualEffectBlur-iOS.swift in Sources */, 37977583268922F600DD52A8 /* InvidiousAPI.swift in Sources */, 37130A5F277657300033018A /* PersistenceController.swift in Sources */, @@ -2731,6 +2767,7 @@ 37B4E805277D0AB4004BF56A /* Orientation.swift in Sources */, 37DD87C7271C9CFE0027CBF9 /* PlayerStreams.swift in Sources */, 37F7D82C289EB05F00E2B3D0 /* SettingsPickerModifier.swift in Sources */, + 375EC95D289EEEE000751258 /* QualityProfile.swift in Sources */, 371B7E662759786B00D21217 /* Comment+Fixtures.swift in Sources */, 37BE0BD326A1D4780092E2DB /* AppleAVPlayerView.swift in Sources */, 37A9965E26D6F9B9006E3224 /* FavoritesView.swift in Sources */, @@ -2746,6 +2783,7 @@ 37BA794726DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */, 3752069D285E910600CA655F /* ChaptersView.swift in Sources */, + 375EC96A289F232600751258 /* QualityProfilesModel.swift in Sources */, 3751B4B227836902000B7DF4 /* SearchPage.swift in Sources */, 37CC3F4C270CFE1700608308 /* PlayerQueueView.swift in Sources */, 37FFC440272734C3009FFD26 /* Throttle.swift in Sources */, @@ -2799,6 +2837,7 @@ 374C053F272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */, 375F7410289DC35A00747050 /* PlayerBackendView.swift in Sources */, 37FB28412721B22200A57617 /* ContentItem.swift in Sources */, + 379F141F289ECE7F00DE48B5 /* QualitySettings.swift in Sources */, 37C3A24D272360470087A57A /* ChannelPlaylist+Fixtures.swift in Sources */, 37484C2D26FC844700287258 /* InstanceSettings.swift in Sources */, 37DD9DA32785BBC900539416 /* NoCommentsView.swift in Sources */, @@ -2835,6 +2874,7 @@ 37DD9DC62785D63A00539416 /* UIResponder+Extensions.swift in Sources */, 37C3A24927235FAA0087A57A /* ChannelPlaylistCell.swift in Sources */, 373CFACB26966264003CB2C6 /* SearchQuery.swift in Sources */, + 37F7AB4D28A9361F00FB46B5 /* UIDevice+Cellular.swift in Sources */, 37141673267A8E10006CA35D /* Country.swift in Sources */, 37FEF11327EFD8580033912F /* PlaceholderCell.swift in Sources */, 37B2631A2735EAAB00FE0D40 /* FavoriteResourceObserver.swift in Sources */, @@ -2903,7 +2943,9 @@ 37CEE4BE2677B670005A1EFE /* SingleAssetStream.swift in Sources */, 3703100327B0713600ECDDAA /* PlayerGestures.swift in Sources */, 374C0540272472C0009BDDBE /* PlayerSponsorBlock.swift in Sources */, + 379F1420289ECE7F00DE48B5 /* QualitySettings.swift in Sources */, 3751BA8027E64244007B1A60 /* VideoLayer.swift in Sources */, + 375EC96B289F232600751258 /* QualityProfilesModel.swift in Sources */, 374C053627242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */, 37BA794826DC2E56002A0235 /* AppSidebarSubscriptions.swift in Sources */, 37E70928271CDDAE00D34DDE /* OpenSettingsButton.swift in Sources */, @@ -2918,6 +2960,7 @@ 371B7E6B2759791900D21217 /* CommentsModel.swift in Sources */, 371F2F1B269B43D300E4A7AB /* NavigationModel.swift in Sources */, 3756C2AB2861151C00E4B059 /* NetworkStateModel.swift in Sources */, + 375EC95A289EEB8200751258 /* QualityProfileForm.swift in Sources */, 37001564271B1F250049C794 /* AccountsModel.swift in Sources */, 378FFBC528660172009E3FBE /* URLParser.swift in Sources */, 3761ABFE26F0F8DE00AA496F /* EnvironmentValues.swift in Sources */, @@ -2952,6 +2995,7 @@ 37F64FE526FE70A60081B69E /* RedrawOnModifier.swift in Sources */, 377ABC45286E4B74009C986F /* ManifestedInstance.swift in Sources */, 3795593727B08538007FF8F4 /* StreamControl.swift in Sources */, + 37F7AB5528A951B200FB46B5 /* Power.swift in Sources */, 372CFD16285F2E2A00B0B54B /* ControlsBar.swift in Sources */, 37FFC441272734C3009FFD26 /* Throttle.swift in Sources */, 37169AA72729E2CC0011DE61 /* AccountsBridge.swift in Sources */, @@ -3029,6 +3073,7 @@ 37A5DBC9285E371400CA4DD1 /* ControlBackgroundModifier.swift in Sources */, 3797758C2689345500DD52A8 /* Store.swift in Sources */, 371B7E622759706A00D21217 /* CommentsView.swift in Sources */, + 375EC95E289EEEE000751258 /* QualityProfile.swift in Sources */, 37141674267A8E10006CA35D /* Country.swift in Sources */, 3703100027B04DCC00ECDDAA /* PlayerControls.swift in Sources */, 37130A5C277657090033018A /* Yattee.xcdatamodeld in Sources */, @@ -3147,9 +3192,11 @@ buildActionMask = 2147483647; files = ( 37579D5F27864F5F00FD0B98 /* Help.swift in Sources */, + 375EC95F289EEEE000751258 /* QualityProfile.swift in Sources */, 37EAD871267B9ED100D9E01B /* Segment.swift in Sources */, 373C8FE6275B955100CB5936 /* CommentsPage.swift in Sources */, 37DD9DBC2785D60300539416 /* FramePreferenceKey.swift in Sources */, + 375EC974289F2ABF00751258 /* MultiselectRow.swift in Sources */, 37EBD8C827AF26B300F1C24B /* AVPlayerBackend.swift in Sources */, 37CC3F52270D010D00608308 /* VideoBanner.swift in Sources */, 37F49BA526CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, @@ -3159,6 +3206,7 @@ 374C053D2724614F009BDDBE /* PlayerTVMenu.swift in Sources */, 37BE0BD426A1D47D0092E2DB /* AppleAVPlayerView.swift in Sources */, 37977585268922F600DD52A8 /* InvidiousAPI.swift in Sources */, + 3769537928A877C4005D87C3 /* StreamControl.swift in Sources */, 3700155D271B0D4D0049C794 /* PipedAPI.swift in Sources */, 375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */, 3769C0302779F18600DDB3EA /* PlaceholderProgressView.swift in Sources */, @@ -3168,6 +3216,7 @@ 3748187026A769D60084E870 /* DetailBadge.swift in Sources */, 3741A32C27E7EFFD00D266D1 /* PlayerControls.swift in Sources */, 371B7E632759706A00D21217 /* CommentsView.swift in Sources */, + 379F1421289ECE7F00DE48B5 /* QualitySettings.swift in Sources */, 37A9965C26D6F8CA006E3224 /* HorizontalCells.swift in Sources */, 3782B95727557E6E00990149 /* SearchSuggestions.swift in Sources */, 3776ADD8287381240078EBC4 /* Captions.swift in Sources */, @@ -3222,6 +3271,7 @@ 37169AA42729D98A0011DE61 /* InstancesBridge.swift in Sources */, 37D4B18E26717B3800C925CA /* VideoCell.swift in Sources */, 375E45F627B1976B00BA7902 /* MPVOGLView.swift in Sources */, + 375EC95B289EEB8200751258 /* QualityProfileForm.swift in Sources */, 371B7E682759786B00D21217 /* Comment+Fixtures.swift in Sources */, 37BE0BD126A0E2D50092E2DB /* VideoPlayerView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularView.swift in Sources */, @@ -3260,6 +3310,7 @@ 37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 37BE0BD726A1D4A90092E2DB /* AppleAVPlayerViewController.swift in Sources */, 37484C3326FCB8F900287258 /* AccountValidator.swift in Sources */, + 375EC96C289F232600751258 /* QualityProfilesModel.swift in Sources */, 372D85DF283842EC00FF3C7D /* PiPDelegate.swift in Sources */, 372D85E0283842EE00FF3C7D /* PlayerLayerView.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, @@ -4297,6 +4348,14 @@ minimumVersion = 5.0.0; }; }; + 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ashleymills/Reachability.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.1.0; + }; + }; 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; @@ -4504,6 +4563,11 @@ package = 37A5DBC2285DFF5400CA4DD1 /* XCRemoteSwiftPackageReference "SwiftUIPager" */; productName = SwiftUIPager; }; + 37EE6DC428A305AD00BFD632 /* Reachability */ = { + isa = XCSwiftPackageProductDependency; + package = 37EE6DC328A305AD00BFD632 /* XCRemoteSwiftPackageReference "Reachability" */; + productName = Reachability; + }; 37FB28452722054C00A57617 /* SDWebImageSwiftUI */ = { isa = XCSwiftPackageProductDependency; package = 37FB28442722054B00A57617 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; diff --git a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b5bc4e8a..8db5a1fa 100644 --- a/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Yattee.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "version" : "1.2.1" } }, + { + "identity" : "reachability.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ashleymills/Reachability.swift", + "state" : { + "revision" : "c01bbdf2d633cf049ae1ed1a68a2020a8bda32e2", + "version" : "5.1.0" + } + }, { "identity" : "repeat", "kind" : "remoteSourceControl", diff --git a/iOS/BridgingHeader.h b/iOS/BridgingHeader.h index 301e35c0..c224966e 100644 --- a/iOS/BridgingHeader.h +++ b/iOS/BridgingHeader.h @@ -1,3 +1,4 @@ +#import #import #import "../Vendor/mpv/include/client.h" #import "../Vendor/mpv/include/render.h" diff --git a/macOS/BridgingHeader.h b/macOS/BridgingHeader.h index 301e35c0..c181a4b9 100644 --- a/macOS/BridgingHeader.h +++ b/macOS/BridgingHeader.h @@ -1,4 +1,5 @@ #import +#import #import "../Vendor/mpv/include/client.h" #import "../Vendor/mpv/include/render.h" #import "../Vendor/mpv/include/render_gl.h" diff --git a/macOS/Power.swift b/macOS/Power.swift new file mode 100644 index 00000000..e0866469 --- /dev/null +++ b/macOS/Power.swift @@ -0,0 +1,38 @@ +import Foundation + +struct Power { + static var hasInternalBattery: Bool { + let psInfo = IOPSCopyPowerSourcesInfo().takeRetainedValue() + let psList = IOPSCopyPowerSourcesList(psInfo).takeRetainedValue() as [CFTypeRef] + + for ps in psList { + if let psDesc = IOPSGetPowerSourceDescription(psInfo, ps).takeUnretainedValue() as? [String: Any] { + if let type = psDesc[kIOPSTypeKey] as? String { + if type == "InternalBattery" { + return true + } + } + } + } + + return false + } + + static var isConnectedToPower: Bool { + let psInfo = IOPSCopyPowerSourcesInfo().takeRetainedValue() + let psList = IOPSCopyPowerSourcesList(psInfo).takeRetainedValue() as [CFTypeRef] + + for ps in psList { + if let psDesc = IOPSGetPowerSourceDescription(psInfo, ps).takeUnretainedValue() as? [String: Any] { + if let type = psDesc[kIOPSTypeKey] as? String, + type == "InternalBattery", + let powerSourceState = (psDesc[kIOPSPowerSourceStateKey] as? String) + { + return powerSourceState == kIOPSACPowerValue + } + } + } + + return false + } +}