diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 629117a..6df9f8e 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -6,6 +6,7 @@ // import Foundation +import Network class FetchDelegate: NSObject, URLSessionTaskDelegate { private let allowRedirects: Bool @@ -70,3 +71,56 @@ extension URLSession { return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) } } + +enum NetworkType { + case wifi + case cellular + case unknown +} + +@available(iOS 14.0, *) +class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + @Published var currentNetworkType: NetworkType = .unknown + @Published var isConnected: Bool = false + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + self?.currentNetworkType = self?.getNetworkType(from: path) ?? .unknown + } + } + monitor.start(queue: queue) + } + + private func getNetworkType(from path: NWPath) -> NetworkType { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.cellular) { + return .cellular + } else { + return .unknown + } + } + + static func getCurrentNetworkType() -> NetworkType { + if #available(iOS 14.0, *) { + return shared.currentNetworkType + } else { + return .unknown + } + } + + deinit { + monitor.cancel() + } +} diff --git a/Sora/Utils/Extensions/UserDefaults.swift b/Sora/Utils/Extensions/UserDefaults.swift index 4d52347..86cb388 100644 --- a/Sora/Utils/Extensions/UserDefaults.swift +++ b/Sora/Utils/Extensions/UserDefaults.swift @@ -7,6 +7,63 @@ import UIKit +enum VideoQualityPreference: String, CaseIterable { + case best = "Best" + case p1080 = "1080p" + case p720 = "720p" + case p420 = "420p" + case p360 = "360p" + case worst = "Worst" + + static let wifiDefaultKey = "videoQualityWiFi" + static let cellularDefaultKey = "videoQualityCellular" + + static let defaultWiFiPreference: VideoQualityPreference = .best + static let defaultCellularPreference: VideoQualityPreference = .p720 + + static let qualityPriority: [VideoQualityPreference] = [.best, .p1080, .p720, .p420, .p360, .worst] + + static func findClosestQuality(preferred: VideoQualityPreference, availableQualities: [(String, String)]) -> (String, String)? { + for (name, url) in availableQualities { + if isQualityMatch(preferred: preferred, qualityName: name) { + return (name, url) + } + } + + let preferredIndex = qualityPriority.firstIndex(of: preferred) ?? qualityPriority.count + + for i in 0.. Bool { + let lowercaseName = qualityName.lowercased() + + switch preferred { + case .best: + return lowercaseName.contains("best") || lowercaseName.contains("highest") || lowercaseName.contains("max") + case .p1080: + return lowercaseName.contains("1080") || lowercaseName.contains("1920") + case .p720: + return lowercaseName.contains("720") || lowercaseName.contains("1280") + case .p420: + return lowercaseName.contains("420") || lowercaseName.contains("480") + case .p360: + return lowercaseName.contains("360") || lowercaseName.contains("640") + case .worst: + return lowercaseName.contains("worst") || lowercaseName.contains("lowest") || lowercaseName.contains("min") + } + } +} + extension UserDefaults { func color(forKey key: String) -> UIColor? { guard let colorData = data(forKey: key) else { return nil } @@ -30,4 +87,19 @@ extension UserDefaults { Logger.shared.log("Error archiving color: \(error)", type: "Error") } } + + static func getVideoQualityPreference() -> VideoQualityPreference { + let networkType = NetworkMonitor.getCurrentNetworkType() + + switch networkType { + case .wifi: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.wifiDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultWiFiPreference + case .cellular: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.cellularDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultCellularPreference + case .unknown: + return .p720 + } + } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index ea2f7bb..07fbf25 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -2211,7 +2211,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func switchToQuality(urlString: String) { guard let url = URL(string: urlString), - currentQualityURL?.absoluteString != urlString else { return } + currentQualityURL?.absoluteString != urlString else { + Logger.shared.log("Quality Selection: Switch cancelled - same quality already selected", type: "General") + return + } + + let qualityName = qualities.first(where: { $0.1 == urlString })?.0 ?? "Unknown" + Logger.shared.log("Quality Selection: Switching to quality: \(qualityName) (\(urlString))", type: "General") let currentTime = player.currentTime() let wasPlaying = player.rate > 0 @@ -2270,7 +2276,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.menu = qualitySelectionMenu() if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { + Logger.shared.log("Quality Selection: Successfully switched to: \(selectedQuality)", type: "General") DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye")) + } else { + Logger.shared.log("Quality Selection: Switch completed but quality name not found in list", type: "General") } } @@ -2320,11 +2329,34 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele baseM3U8URL = url currentQualityURL = url + let networkType = NetworkMonitor.getCurrentNetworkType() + let networkTypeString = networkType == .wifi ? "WiFi" : networkType == .cellular ? "Cellular" : "Unknown" + Logger.shared.log("Quality Selection: Detected network type: \(networkTypeString)", type: "General") + parseM3U8(url: url) { [weak self] in guard let self = self else { return } - if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), - self.qualities.contains(where: { $0.1 == last }) { - self.switchToQuality(urlString: last) + + Logger.shared.log("Quality Selection: Found \(self.qualities.count) available qualities", type: "General") + for (index, quality) in self.qualities.enumerated() { + Logger.shared.log("Quality Selection: Available [\(index + 1)]: \(quality.0) - \(quality.1)", type: "General") + } + + let preferredQuality = UserDefaults.getVideoQualityPreference() + Logger.shared.log("Quality Selection: User preference for \(networkTypeString): \(preferredQuality.rawValue)", type: "General") + + if let selectedQuality = VideoQualityPreference.findClosestQuality(preferred: preferredQuality, availableQualities: self.qualities) { + Logger.shared.log("Quality Selection: Selected quality: \(selectedQuality.0) (URL: \(selectedQuality.1))", type: "General") + self.switchToQuality(urlString: selectedQuality.1) + } else { + Logger.shared.log("Quality Selection: No matching quality found, using default", type: "General") + if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), + self.qualities.contains(where: { $0.1 == last }) { + Logger.shared.log("Quality Selection: Falling back to last selected quality", type: "General") + self.switchToQuality(urlString: last) + } else if let firstQuality = self.qualities.first { + Logger.shared.log("Quality Selection: Falling back to first available quality: \(firstQuality.0)", type: "General") + self.switchToQuality(urlString: firstQuality.1) + } } self.qualityButton.isHidden = false @@ -2338,6 +2370,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isHLSStream = false qualityButton.isHidden = true updateMenuButtonConstraints() + Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General") } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 9ce67eb..6eceb11 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -205,7 +205,11 @@ struct SettingsViewPlayer: View { @AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true @AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true + @AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue + @AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue + private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"] + private let qualityOptions = VideoQualityPreference.allCases.map { $0.rawValue } var body: some View { ScrollView { @@ -261,6 +265,28 @@ struct SettingsViewPlayer: View { ) } + SettingsSection( + title: "Video Quality Preferences", + footer: "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + ) { + SettingsPickerRow( + icon: "wifi", + title: "WiFi Quality", + options: qualityOptions, + optionToString: { $0 }, + selection: $wifiQuality + ) + + SettingsPickerRow( + icon: "antenna.radiowaves.left.and.right", + title: "Cellular Quality", + options: qualityOptions, + optionToString: { $0 }, + selection: $cellularQuality, + showDivider: false + ) + } + SettingsSection(title: "Progress bar Marker Color") { ColorPicker("Segments Color", selection: Binding( get: {