From 5d5365949e328b68e4fdf13544d816cabb634046 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:05:47 +0200 Subject: [PATCH] Revert "bunch of stuffs" This reverts commit 611802607e30bdf10a5c078ed3336d4c36f60d09. --- Sora/Managers/ImagePrefetchManager.swift | 134 +++++ Sora/Managers/ImageUpscaler.swift | 165 ++++++ Sora/Managers/PerformanceMonitor.swift | 510 ++++++++++++++++++ .../Tracking Services/TMDB/TMDB-FetchID.swift | 7 +- .../EpisodeCell/EpisodeCell.swift | 8 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 2 + .../SettingsViewGeneral.swift | 2 +- Sulfur.xcodeproj/project.pbxproj | 14 +- 8 files changed, 837 insertions(+), 5 deletions(-) create mode 100644 Sora/Managers/ImagePrefetchManager.swift create mode 100644 Sora/Managers/ImageUpscaler.swift create mode 100644 Sora/Managers/PerformanceMonitor.swift diff --git a/Sora/Managers/ImagePrefetchManager.swift b/Sora/Managers/ImagePrefetchManager.swift new file mode 100644 index 0000000..a4d342c --- /dev/null +++ b/Sora/Managers/ImagePrefetchManager.swift @@ -0,0 +1,134 @@ +// +// ImagePrefetchManager.swift +// Sora +// +// Created by doomsboygaming on 5/22/25 +// + +import Foundation +import Kingfisher +import UIKit + +/// Manager for image prefetching, caching, and optimization +class ImagePrefetchManager { + static let shared = ImagePrefetchManager() + + // Prefetcher for batch prefetching images + private let prefetcher = ImagePrefetcher( + urls: [], + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))), + .scaleFactor(UIScreen.main.scale), + .cacheOriginalImage + ] + ) + + // Keep track of what's already prefetched to avoid duplication + private var prefetchedURLs = Set() + private let prefetchQueue = DispatchQueue(label: "com.sora.imagePrefetch", qos: .utility) + + init() { + // Set up KingfisherManager for optimal image loading + ImageCache.default.memoryStorage.config.totalCostLimit = 300 * 1024 * 1024 // 300MB + ImageCache.default.diskStorage.config.sizeLimit = 1000 * 1024 * 1024 // 1GB + ImageDownloader.default.downloadTimeout = 15.0 // 15 seconds + } + + /// Prefetch a batch of images + func prefetchImages(_ urls: [String]) { + prefetchQueue.async { [weak self] in + guard let self = self else { return } + + // Filter out already prefetched URLs and invalid URLs + let urlObjects = urls.compactMap { URL(string: $0) } + .filter { !self.prefetchedURLs.contains($0) } + + guard !urlObjects.isEmpty else { return } + + // Create a new prefetcher with the URLs and start it + let newPrefetcher = ImagePrefetcher( + urls: urlObjects, + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))), + .scaleFactor(UIScreen.main.scale), + .cacheOriginalImage + ] + ) + newPrefetcher.start() + + // Track prefetched URLs + urlObjects.forEach { self.prefetchedURLs.insert($0) } + } + } + + /// Prefetch a single image + func prefetchImage(_ url: String) { + guard let urlObject = URL(string: url), + !prefetchedURLs.contains(urlObject) else { + return + } + + prefetchQueue.async { [weak self] in + guard let self = self else { return } + + // Create a new prefetcher with the URL and start it + let newPrefetcher = ImagePrefetcher( + urls: [urlObject], + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))), + .scaleFactor(UIScreen.main.scale), + .cacheOriginalImage + ] + ) + newPrefetcher.start() + + // Track prefetched URL + self.prefetchedURLs.insert(urlObject) + } + } + + /// Prefetch episode images for a batch of episodes + func prefetchEpisodeImages(anilistId: Int, startEpisode: Int, count: Int) { + prefetchQueue.async { [weak self] in + guard let self = self else { return } + + // Get metadata for episodes in the range + for episodeNumber in startEpisode...(startEpisode + count) where episodeNumber > 0 { + EpisodeMetadataManager.shared.fetchMetadata(anilistId: anilistId, episodeNumber: episodeNumber) { result in + switch result { + case .success(let metadata): + self.prefetchImage(metadata.imageUrl) + case .failure: + break + } + } + } + } + } + + /// Clear prefetch queue and stop any ongoing prefetch operations + func cancelPrefetching() { + prefetcher.stop() + } +} + +// MARK: - KFImage Extension +extension KFImage { + /// Load an image with optimal settings for episode thumbnails + static func optimizedEpisodeThumbnail(url: URL?) -> KFImage { + return KFImage(url) + .setProcessor(DownsamplingImageProcessor(size: CGSize(width: 100, height: 56))) + .memoryCacheExpiration(.seconds(300)) + .cacheOriginalImage() + .fade(duration: 0.25) + .onProgress { _, _ in + // Track progress if needed + } + .onSuccess { _ in + // Success logger removed to reduce logs + } + .onFailure { error in + Logger.shared.log("Failed to load image: \(error)", type: "Error") + } + } +} \ No newline at end of file diff --git a/Sora/Managers/ImageUpscaler.swift b/Sora/Managers/ImageUpscaler.swift new file mode 100644 index 0000000..70643d8 --- /dev/null +++ b/Sora/Managers/ImageUpscaler.swift @@ -0,0 +1,165 @@ +// +// ImageUpscaler.swift +// Sulfur +// +// Created by seiike on 26/05/2025. +// + + +import UIKit +import CoreImage +import CoreImage.CIFilterBuiltins +import Vision +import CoreML +import Kingfisher + +public enum ImageUpscaler { + /// Lanczos interpolation + unsharp mask for sharper upscaling. + /// - Parameters: + /// - scale: The factor to upscale (e.g. 2.0 doubles width/height). + /// - sharpeningIntensity: The unsharp mask intensity (0...1). + /// - sharpeningRadius: The unsharp mask radius in pixels. + public static func lanczosProcessor( + scale: CGFloat, + sharpeningIntensity: Float = 0.7, + sharpeningRadius: Float = 2.0 + ) -> ImageProcessor { + return LanczosUpscaleProcessor( + scale: scale, + sharpeningIntensity: sharpeningIntensity, + sharpeningRadius: sharpeningRadius + ) + } + + public static func superResolutionProcessor(modelURL: URL) -> ImageProcessor { + return MLScaleProcessor(modelURL: modelURL) + } +} + +// MARK: - Lanczos + Unsharp Mask Processor +public struct LanczosUpscaleProcessor: ImageProcessor { + public let scale: CGFloat + public let sharpeningIntensity: Float + public let sharpeningRadius: Float + public var identifier: String { + "com.yourapp.lanczos_\(scale)_sharp_\(sharpeningIntensity)_\(sharpeningRadius)" + } + + public init( + scale: CGFloat, + sharpeningIntensity: Float = 0.7, + sharpeningRadius: Float = 2.0 + ) { + self.scale = scale + self.sharpeningIntensity = sharpeningIntensity + self.sharpeningRadius = sharpeningRadius + } + + public func process( + item: ImageProcessItem, + options: KingfisherParsedOptionsInfo + ) -> KFCrossPlatformImage? { + + let inputImage: KFCrossPlatformImage? + switch item { + case .image(let image): + inputImage = image + case .data(let data): + inputImage = KFCrossPlatformImage(data: data) + } + guard let uiImage = inputImage, + let cgImage = uiImage.cgImage else { + return nil + } + + let ciInput = CIImage(cgImage: cgImage) + + let scaleFilter = CIFilter.lanczosScaleTransform() + scaleFilter.inputImage = ciInput + scaleFilter.scale = Float(scale) + scaleFilter.aspectRatio = 1.0 + guard let scaledCI = scaleFilter.outputImage else { + return uiImage + } + + let unsharp = CIFilter.unsharpMask() + unsharp.inputImage = scaledCI + unsharp.intensity = sharpeningIntensity + unsharp.radius = sharpeningRadius + guard let sharpCI = unsharp.outputImage else { + return UIImage(ciImage: scaledCI) + } + + let context = CIContext(options: nil) + guard let outputCG = context.createCGImage(sharpCI, from: sharpCI.extent) else { + return UIImage(ciImage: sharpCI) + } + return KFCrossPlatformImage(cgImage: outputCG) + } +} + +// MARK: - Core ML Super-Resolution Processor +public struct MLScaleProcessor: ImageProcessor { + private let request: VNCoreMLRequest + private let ciContext = CIContext() + public let identifier: String + + public init(modelURL: URL) { + + self.identifier = "com.yourapp.ml_sr_\(modelURL.lastPathComponent)" + guard let mlModel = try? MLModel(contentsOf: modelURL), + let visionModel = try? VNCoreMLModel(for: mlModel) else { + fatalError("Failed to load Core ML model at \(modelURL)") + } + let req = VNCoreMLRequest(model: visionModel) + req.imageCropAndScaleOption = .scaleFill + self.request = req + } + + public func process( + item: ImageProcessItem, + options: KingfisherParsedOptionsInfo + ) -> KFCrossPlatformImage? { + + let inputImage: KFCrossPlatformImage? + switch item { + case .image(let image): + inputImage = image + case .data(let data): + inputImage = KFCrossPlatformImage(data: data) + } + guard let uiImage = inputImage, + let cgImage = uiImage.cgImage else { + return nil + } + + let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) + do { + try handler.perform([request]) + } catch { + print("[MLScaleProcessor] Vision error: \(error)") + return uiImage + } + guard let obs = request.results?.first as? VNPixelBufferObservation else { + return uiImage + } + + let ciOutput = CIImage(cvPixelBuffer: obs.pixelBuffer) + let rect = CGRect( + origin: .zero, + size: CGSize( + width: CVPixelBufferGetWidth(obs.pixelBuffer), + height: CVPixelBufferGetHeight(obs.pixelBuffer) + ) + ) + guard let finalCG = ciContext.createCGImage(ciOutput, from: rect) else { + return uiImage + } + return KFCrossPlatformImage(cgImage: finalCG) + } +} + +// the sweet spot (for mediainfoview poster) +// .setProcessor(ImageUpscaler.lanczosProcessor(scale: 3.2, +// sharpeningIntensity: 0.75, +// sharpeningRadius: 2.25)) diff --git a/Sora/Managers/PerformanceMonitor.swift b/Sora/Managers/PerformanceMonitor.swift new file mode 100644 index 0000000..3a45fae --- /dev/null +++ b/Sora/Managers/PerformanceMonitor.swift @@ -0,0 +1,510 @@ +// +// PerformanceMonitor.swift +// Sora +// +// Created by doomsboygaming on 5/22/25 +// + +import Foundation +import SwiftUI +import Kingfisher +import QuartzCore + +/// Performance metrics tracking system with advanced jitter detection +class PerformanceMonitor: ObservableObject { + static let shared = PerformanceMonitor() + + // Published properties to allow UI observation + @Published private(set) var networkRequestCount: Int = 0 + @Published private(set) var cacheHitCount: Int = 0 + @Published private(set) var cacheMissCount: Int = 0 + @Published private(set) var averageLoadTime: TimeInterval = 0 + @Published private(set) var memoryUsage: UInt64 = 0 + @Published private(set) var diskUsage: UInt64 = 0 + @Published private(set) var isEnabled: Bool = false + + // Advanced performance metrics for jitter detection + @Published private(set) var currentFPS: Double = 60.0 + @Published private(set) var mainThreadBlocks: Int = 0 + @Published private(set) var memorySpikes: Int = 0 + @Published private(set) var cpuUsage: Double = 0.0 + @Published private(set) var jitterEvents: Int = 0 + + // Internal tracking properties + private var loadTimes: [TimeInterval] = [] + private var startTimes: [String: Date] = [:] + private var memoryTimer: Timer? + private var logTimer: Timer? + + // Advanced monitoring properties + private var displayLink: CADisplayLink? + private var frameCount: Int = 0 + private var lastFrameTime: CFTimeInterval = 0 + private var frameTimes: [CFTimeInterval] = [] + private var lastMemoryUsage: UInt64 = 0 + private var mainThreadOperations: [String: CFTimeInterval] = [:] + private var cpuTimer: Timer? + + // Thresholds for performance issues + private let mainThreadBlockingThreshold: TimeInterval = 0.016 // 16ms for 60fps + private let memorySpikeTreshold: UInt64 = 50 * 1024 * 1024 // 50MB spike + private let fpsThreshold: Double = 50.0 // Below 50fps is considered poor + + private init() { + // Default is off unless explicitly enabled + isEnabled = UserDefaults.standard.bool(forKey: "enablePerformanceMonitoring") + + // Setup memory monitoring if enabled + if isEnabled { + startMonitoring() + } + } + + // MARK: - Public Methods + + /// Enable or disable the performance monitoring + func setEnabled(_ enabled: Bool) { + isEnabled = enabled + UserDefaults.standard.set(enabled, forKey: "enablePerformanceMonitoring") + + if enabled { + startMonitoring() + } else { + stopMonitoring() + } + } + + /// Reset all tracked metrics + func resetMetrics() { + networkRequestCount = 0 + cacheHitCount = 0 + cacheMissCount = 0 + averageLoadTime = 0 + loadTimes = [] + startTimes = [:] + + // Reset advanced metrics + mainThreadBlocks = 0 + memorySpikes = 0 + jitterEvents = 0 + frameTimes = [] + frameCount = 0 + mainThreadOperations = [:] + + updateMemoryUsage() + + Logger.shared.log("Performance metrics reset", type: "Debug") + } + + /// Track a network request starting + func trackRequestStart(identifier: String) { + guard isEnabled else { return } + + networkRequestCount += 1 + startTimes[identifier] = Date() + } + + /// Track a network request completing + func trackRequestEnd(identifier: String) { + guard isEnabled, let startTime = startTimes[identifier] else { return } + + let endTime = Date() + let duration = endTime.timeIntervalSince(startTime) + loadTimes.append(duration) + + // Update average load time + if !loadTimes.isEmpty { + averageLoadTime = loadTimes.reduce(0, +) / Double(loadTimes.count) + } + + // Remove start time to avoid memory leaks + startTimes.removeValue(forKey: identifier) + } + + /// Track a cache hit + func trackCacheHit() { + guard isEnabled else { return } + cacheHitCount += 1 + } + + /// Track a cache miss + func trackCacheMiss() { + guard isEnabled else { return } + cacheMissCount += 1 + } + + // MARK: - Advanced Performance Monitoring + + /// Track the start of a main thread operation + func trackMainThreadOperationStart(operation: String) { + guard isEnabled else { return } + mainThreadOperations[operation] = CACurrentMediaTime() + } + + /// Track the end of a main thread operation and detect blocking + func trackMainThreadOperationEnd(operation: String) { + guard isEnabled, let startTime = mainThreadOperations[operation] else { return } + + let endTime = CACurrentMediaTime() + let duration = endTime - startTime + + if duration > mainThreadBlockingThreshold { + mainThreadBlocks += 1 + jitterEvents += 1 + + let durationMs = Int(duration * 1000) + Logger.shared.log("🚨 Main thread blocked for \(durationMs)ms during: \(operation)", type: "Performance") + } + + mainThreadOperations.removeValue(forKey: operation) + } + + /// Track memory spikes during downloads + func checkMemorySpike() { + guard isEnabled else { return } + + let currentMemory = getAppMemoryUsage() + + if lastMemoryUsage > 0 { + let spike = currentMemory > lastMemoryUsage ? currentMemory - lastMemoryUsage : 0 + + if spike > memorySpikeTreshold { + memorySpikes += 1 + jitterEvents += 1 + + let spikeSize = Double(spike) / (1024 * 1024) + Logger.shared.log("🚨 Memory spike detected: +\(String(format: "%.1f", spikeSize))MB", type: "Performance") + } + } + + lastMemoryUsage = currentMemory + memoryUsage = currentMemory + } + + /// Start frame rate monitoring + private func startFrameRateMonitoring() { + guard displayLink == nil else { return } + + displayLink = CADisplayLink(target: self, selector: #selector(frameCallback)) + displayLink?.add(to: .main, forMode: .common) + + frameCount = 0 + lastFrameTime = CACurrentMediaTime() + frameTimes = [] + } + + /// Stop frame rate monitoring + private func stopFrameRateMonitoring() { + displayLink?.invalidate() + displayLink = nil + } + + /// Frame callback for FPS monitoring + @objc private func frameCallback() { + let currentTime = CACurrentMediaTime() + + if lastFrameTime > 0 { + let frameDuration = currentTime - lastFrameTime + frameTimes.append(frameDuration) + + // Keep only last 60 frames for rolling average + if frameTimes.count > 60 { + frameTimes.removeFirst() + } + + // Calculate current FPS + if !frameTimes.isEmpty { + let averageFrameTime = frameTimes.reduce(0, +) / Double(frameTimes.count) + currentFPS = 1.0 / averageFrameTime + + // Detect FPS drops + if currentFPS < fpsThreshold { + jitterEvents += 1 + Logger.shared.log("🚨 FPS drop detected: \(String(format: "%.1f", currentFPS))fps", type: "Performance") + } + } + } + + lastFrameTime = currentTime + frameCount += 1 + } + + /// Get current CPU usage + private func getCPUUsage() -> Double { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + if kerr == KERN_SUCCESS { + // This is a simplified CPU usage calculation + // For more accurate results, we'd need to track over time + return Double(info.user_time.seconds + info.system_time.seconds) + } else { + return 0.0 + } + } + + /// Get the current cache hit rate + var cacheHitRate: Double { + let total = cacheHitCount + cacheMissCount + guard total > 0 else { return 0 } + return Double(cacheHitCount) / Double(total) + } + + /// Log current performance metrics + func logMetrics() { + guard isEnabled else { return } + + checkMemorySpike() + + let hitRate = String(format: "%.1f%%", cacheHitRate * 100) + let avgLoad = String(format: "%.2f", averageLoadTime) + let memory = String(format: "%.1f MB", Double(memoryUsage) / (1024 * 1024)) + let disk = String(format: "%.1f MB", Double(diskUsage) / (1024 * 1024)) + let fps = String(format: "%.1f", currentFPS) + let cpu = String(format: "%.1f%%", cpuUsage) + + let metrics = """ + 📊 Performance Metrics Report: + ═══════════════════════════════ + Network & Cache: + - Network Requests: \(networkRequestCount) + - Cache Hit Rate: \(hitRate) (\(cacheHitCount)/\(cacheHitCount + cacheMissCount)) + - Average Load Time: \(avgLoad)s + + System Resources: + - Memory Usage: \(memory) + - Disk Usage: \(disk) + - CPU Usage: \(cpu) + + Performance Issues: + - Current FPS: \(fps) + - Main Thread Blocks: \(mainThreadBlocks) + - Memory Spikes: \(memorySpikes) + - Total Jitter Events: \(jitterEvents) + ═══════════════════════════════ + """ + + Logger.shared.log(metrics, type: "Performance") + + // Alert if performance is poor + if jitterEvents > 0 { + Logger.shared.log("⚠️ Performance issues detected! Check logs above for details.", type: "Warning") + } + } + + // MARK: - Private Methods + + private func startMonitoring() { + // Setup timer to update memory usage periodically and check for spikes + memoryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in + self?.checkMemorySpike() + } + + // Setup timer to log metrics periodically + logTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + self?.logMetrics() + } + + // Setup CPU monitoring timer + cpuTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in + self?.cpuUsage = self?.getCPUUsage() ?? 0.0 + } + + // Make sure timers run even when scrolling + RunLoop.current.add(memoryTimer!, forMode: .common) + RunLoop.current.add(logTimer!, forMode: .common) + RunLoop.current.add(cpuTimer!, forMode: .common) + + // Start frame rate monitoring + startFrameRateMonitoring() + + Logger.shared.log("Advanced performance monitoring started - tracking FPS, main thread blocks, memory spikes", type: "Debug") + } + + private func stopMonitoring() { + memoryTimer?.invalidate() + memoryTimer = nil + + logTimer?.invalidate() + logTimer = nil + + cpuTimer?.invalidate() + cpuTimer = nil + + stopFrameRateMonitoring() + + Logger.shared.log("Performance monitoring stopped", type: "Debug") + } + + private func updateMemoryUsage() { + memoryUsage = getAppMemoryUsage() + diskUsage = getCacheDiskUsage() + } + + private func getAppMemoryUsage() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + if kerr == KERN_SUCCESS { + return info.resident_size + } else { + return 0 + } + } + + private func getCacheDiskUsage() -> UInt64 { + // Try to get Kingfisher's disk cache size + let diskCache = ImageCache.default.diskStorage + + do { + let size = try diskCache.totalSize() + return UInt64(size) + } catch { + Logger.shared.log("Failed to get disk cache size: \(error)", type: "Error") + return 0 + } + } +} + +// MARK: - Extensions to integrate with managers + +extension EpisodeMetadataManager { + /// Integrate performance tracking + func trackFetchStart(anilistId: Int, episodeNumber: Int) { + let identifier = "metadata_\(anilistId)_\(episodeNumber)" + PerformanceMonitor.shared.trackRequestStart(identifier: identifier) + } + + func trackFetchEnd(anilistId: Int, episodeNumber: Int) { + let identifier = "metadata_\(anilistId)_\(episodeNumber)" + PerformanceMonitor.shared.trackRequestEnd(identifier: identifier) + } + + func trackCacheHit() { + PerformanceMonitor.shared.trackCacheHit() + } + + func trackCacheMiss() { + PerformanceMonitor.shared.trackCacheMiss() + } +} + +extension ImagePrefetchManager { + /// Integrate performance tracking + func trackImageLoadStart(url: String) { + let identifier = "image_\(url.hashValue)" + PerformanceMonitor.shared.trackRequestStart(identifier: identifier) + } + + func trackImageLoadEnd(url: String) { + let identifier = "image_\(url.hashValue)" + PerformanceMonitor.shared.trackRequestEnd(identifier: identifier) + } + + func trackImageCacheHit() { + PerformanceMonitor.shared.trackCacheHit() + } + + func trackImageCacheMiss() { + PerformanceMonitor.shared.trackCacheMiss() + } +} + +// MARK: - Debug View +struct PerformanceMetricsView: View { + @ObservedObject private var monitor = PerformanceMonitor.shared + @State private var isExpanded = false + + var body: some View { + VStack { + HStack { + Text("Performance Metrics") + .font(.headline) + + Spacer() + + Button(action: { + isExpanded.toggle() + }) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + } + } + .padding(.horizontal) + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + Text("Network Requests: \(monitor.networkRequestCount)") + Text("Cache Hit Rate: \(Int(monitor.cacheHitRate * 100))%") + Text("Avg Load Time: \(String(format: "%.2f", monitor.averageLoadTime))s") + Text("Memory: \(String(format: "%.1f MB", Double(monitor.memoryUsage) / (1024 * 1024)))") + + Divider() + + // Advanced metrics + Text("FPS: \(String(format: "%.1f", monitor.currentFPS))") + .foregroundColor(monitor.currentFPS < 50 ? .red : .primary) + Text("Main Thread Blocks: \(monitor.mainThreadBlocks)") + .foregroundColor(monitor.mainThreadBlocks > 0 ? .red : .primary) + Text("Memory Spikes: \(monitor.memorySpikes)") + .foregroundColor(monitor.memorySpikes > 0 ? .orange : .primary) + Text("Jitter Events: \(monitor.jitterEvents)") + .foregroundColor(monitor.jitterEvents > 0 ? .red : .primary) + + HStack { + Button(action: { + monitor.resetMetrics() + }) { + Text("Reset") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(4) + } + + Button(action: { + monitor.logMetrics() + }) { + Text("Log") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.green) + .foregroundColor(.white) + .cornerRadius(4) + } + + Toggle("", isOn: Binding( + get: { monitor.isEnabled }, + set: { monitor.setEnabled($0) } + )) + .labelsHidden() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.bottom, 8) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + } + } + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(8) + .padding(8) + } +} \ No newline at end of file diff --git a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift index 9ada479..818bc96 100644 --- a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift +++ b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift @@ -23,6 +23,9 @@ class TMDBFetcher { let results: [TMDBResult] } + private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca" + private let session = URLSession.custom + func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) { let group = DispatchGroup() var bestResults: [(id: Int, score: Double, type: MediaType)] = [] @@ -45,13 +48,13 @@ class TMDBFetcher { private func fetchBestMatchID(for title: String, type: MediaType, completion: @escaping (Int?, Double?) -> Void) { let query = title.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=738b4edd0a156cc126dc4a4b8aea4aca&query=\(query)" + let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)" guard let url = URL(string: urlString) else { completion(nil, nil) return } - URLSession.custom.dataTask(with: url) { data, _, error in + session.dataTask(with: url) { data, _, error in guard let data = data, error == nil else { completion(nil, nil) return diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 8095d0e..28ddb12 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -895,6 +895,8 @@ struct EpisodeCell: View { let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" guard let url = URL(string: urlString) else { return } + let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" + URLSession.custom.dataTask(with: url) { data, _, error in guard let data = data, error == nil else { return } do { @@ -903,7 +905,11 @@ struct EpisodeCell: View { let stillPath = json["still_path"] as? String let imageUrl: String if let stillPath = stillPath { - imageUrl = "https://image.tmdb.org/t/p/w300\(stillPath)" + if tmdbImageWidth == "original" { + imageUrl = "https://image.tmdb.org/t/p/original\(stillPath)" + } else { + imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(stillPath)" + } } else { imageUrl = "" } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index bf07c6e..2b33e51 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -212,12 +212,14 @@ struct MediaInfoView: View { .fill(Color.gray.opacity(0.3)) .shimmering() } + .setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1.5, sharpeningRadius: 0.8)) .resizable() .aspectRatio(contentMode: .fill) .frame(width: UIScreen.main.bounds.width, height: 700) .clipped() KFImage(URL(string: imageUrl)) .placeholder { EmptyView() } + .setProcessor(ImageUpscaler.lanczosProcessor(scale: 3, sharpeningIntensity: 1.5, sharpeningRadius: 0.8)) .resizable() .aspectRatio(contentMode: .fill) .frame(width: UIScreen.main.bounds.width, height: 700) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 9588545..c91bb09 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -236,7 +236,7 @@ struct SettingsViewGeneral: View { SettingsPickerRow( icon: "square.stack.3d.down.right", - title: "Poster Quality", + title: "Thumbnails Width", options: TMDBimageWidhtList, optionToString: { $0 }, selection: $TMDBimageWidht, diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index cc5ca4a..2118699 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; }; 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; + 1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; }; 7205AEDB2DCCEF9500943F3F /* EpisodeMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */; }; @@ -97,7 +98,9 @@ 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; }; + 72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */; }; 72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */; }; + 72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -178,6 +181,7 @@ 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; + 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageUpscaler.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; }; 7205AED72DCCEF9500943F3F /* EpisodeMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadata.swift; sourceTree = ""; }; @@ -193,6 +197,8 @@ 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; }; 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeMetadataManager.swift; sourceTree = ""; }; + 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePrefetchManager.swift; sourceTree = ""; }; + 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMonitor.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -321,9 +327,9 @@ 133D7C6C2D2BE2500075467E /* Sora */ = { isa = PBXGroup; children = ( + 72AC3A002DD4DAEA00C60B96 /* Managers */, 130C6BF82D53A4C200DC1432 /* Sora.entitlements */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */, - 72AC3A002DD4DAEA00C60B96 /* Managers */, 13103E802D589D6C000F0673 /* Tracking Services */, 133D7C852D2BE2640075467E /* Utils */, 133D7C7B2D2BE2630075467E /* Views */, @@ -623,7 +629,10 @@ 72AC3A002DD4DAEA00C60B96 /* Managers */ = { isa = PBXGroup; children = ( + 1EA64DCC2DE5030100AC14BC /* ImageUpscaler.swift */, 72AC39FD2DD4DAEA00C60B96 /* EpisodeMetadataManager.swift */, + 72AC39FE2DD4DAEA00C60B96 /* ImagePrefetchManager.swift */, + 72AC39FF2DD4DAEA00C60B96 /* PerformanceMonitor.swift */, ); path = Managers; sourceTree = ""; @@ -779,12 +788,15 @@ 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */, 132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */, + 1EA64DCD2DE5030100AC14BC /* ImageUpscaler.swift in Sources */, 0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */, 0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */, 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */, + 72AC3A012DD4DAEB00C60B96 /* ImagePrefetchManager.swift in Sources */, 72AC3A022DD4DAEB00C60B96 /* EpisodeMetadataManager.swift in Sources */, + 72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */, 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */, 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,