diff --git a/Sora/Views/MediaInfoView/Header/AmbientColor.swift b/Sora/Views/MediaInfoView/Header/AmbientColor.swift new file mode 100644 index 0000000..6d3f9e2 --- /dev/null +++ b/Sora/Views/MediaInfoView/Header/AmbientColor.swift @@ -0,0 +1,160 @@ +// +// AmbientColor.swift +// Sora +// +// Created by Francesco on 07/08/25. +// + +import UIKit +import SwiftUI +import Accelerate + +// How does it work uh? On hopes and beliving on him? Why? Cuz i had to learn HSV, and how to work twith it yayy. +extension Color { + static func ambientColor(from image: UIImage?, prioritizeBottom: Bool = true) -> Color { + guard let image = image else { return .black } + + let targetSize = CGSize(width: 64, height: 64) + guard let resizedImage = image.resized(to: targetSize, contentMode: .scaleAspectFill), + let cgImage = resizedImage.cgImage else { return .black } + + let width = cgImage.width + let height = cgImage.height + + let bytesPerPixel = 4 + let bytesPerRow = width * bytesPerPixel + let totalBytes = height * bytesPerRow + + guard let data = malloc(totalBytes) else { return .black } + defer { free(data) } + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue + + guard let context = CGContext(data: data, width: width, height: height, bitsPerComponent: 8, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo) else { + return .black + } + + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let buffer = data.bindMemory(to: UInt8.self, capacity: totalBytes) + + var redSum: Float = 0 + var greenSum: Float = 0 + var blueSum: Float = 0 + var totalWeight: Float = 0 + + for y in 0.. 0.1 && (red + green + blue) > 0.15 else { continue } + + let brightness = red * 0.2126 + green * 0.7152 + blue * 0.0722 + let brightnessWeight = 1.0 - abs(brightness - 0.5) * 0.4 + + let maxComponent = max(red, max(green, blue)) + let minComponent = min(red, min(green, blue)) + let saturation = maxComponent > 0 ? (maxComponent - minComponent) / maxComponent : 0 + let saturationWeight = 0.5 + saturation * 0.5 + + let centerX = Float(width) / 2 + let centerY = Float(height) / 2 + let dx = Float(x) - centerX + let dy = Float(y) - centerY + let distance = sqrt(dx * dx + dy * dy) + let maxDistance = sqrt(centerX * centerX + centerY * centerY) + let centerWeight = 1.0 - (distance / maxDistance) * 0.3 + + let verticalWeight: Float + if prioritizeBottom { + let normalizedY = Float(y) / Float(height) + verticalWeight = 0.3 + 0.7 * (normalizedY * normalizedY) + } else { + verticalWeight = 1.0 + } + + let finalWeight = centerWeight * brightnessWeight * saturationWeight * alpha * verticalWeight + + redSum += red * finalWeight + greenSum += green * finalWeight + blueSum += blue * finalWeight + totalWeight += finalWeight + } + } + + guard totalWeight > 0 else { return .black } + + let avgRed = redSum / totalWeight + let avgGreen = greenSum / totalWeight + let avgBlue = blueSum / totalWeight + + let (h, s, v) = rgbToHsv(r: Double(avgRed), g: Double(avgGreen), b: Double(avgBlue)) + + let adjustedS = min(s * 1.2, 1.0) + let adjustedV = v * 0.7 + + let (r, g, b) = hsvToRgb(h: h, s: adjustedS, v: adjustedV) + + return Color(red: r, green: g, blue: b) + } + + private static func rgbToHsv(r: Double, g: Double, b: Double) -> (h: Double, s: Double, v: Double) { + let minVal = min(r, min(g, b)) + let maxVal = max(r, max(g, b)) + let delta = maxVal - minVal + + var h: Double = 0 + var s: Double = 0 + let v: Double = maxVal + + if delta > 0 { + s = delta / maxVal + + if r == maxVal { + h = (g - b) / delta + } else if g == maxVal { + h = 2 + (b - r) / delta + } else { + h = 4 + (r - g) / delta + } + + h *= 60 + if h < 0 { + h += 360 + } + } + + return (h, s, v) + } + + private static func hsvToRgb(h: Double, s: Double, v: Double) -> (r: Double, g: Double, b: Double) { + if s == 0 { + return (v, v, v) + } + + var h = h + if h >= 360 { h = 0 } + h /= 60 + + let i = Int(h) + let f = h - Double(i) + let p = v * (1 - s) + let q = v * (1 - s * f) + let t = v * (1 - s * (1 - f)) + + switch i { + case 0: return (v, t, p) + case 1: return (q, v, p) + case 2: return (p, v, t) + case 3: return (p, q, v) + case 4: return (t, p, v) + default: return (v, p, q) + } + } +} diff --git a/Sora/Views/MediaInfoView/Header/StretchyHeader.swift b/Sora/Views/MediaInfoView/Header/StretchyHeader.swift new file mode 100644 index 0000000..3e79f7e --- /dev/null +++ b/Sora/Views/MediaInfoView/Header/StretchyHeader.swift @@ -0,0 +1,84 @@ +// +// StretchyHeader.swift +// Sora +// +// Created by Francesco on 07/08/25. +// + +import SwiftUI +import Nuke +import NukeUI + +struct StretchyHeaderView: View { + let backdropURL: String? + let headerHeight: CGFloat + let minHeaderHeight: CGFloat + + @State private var localAmbientColor: Color = Color.black + @State private var backdropImage: UIImage? + + var body: some View { + GeometryReader { geometry in + let frame = geometry.frame(in: .global) + let deltaY = frame.minY + let height = headerHeight + max(0, deltaY) + let offset = min(0, -deltaY) + + ZStack(alignment: .bottom) { + Color.clear + .overlay( + LazyImage(url: URL(string: backdropURL ?? "")) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + } + .onCompletion { result in + if case .success(let response) = result { + let uiImage = response.image + backdropImage = uiImage + let extractedColor = Color.ambientColor(from: uiImage) + localAmbientColor = extractedColor + } + }, + alignment: .center + ) + .clipped() + .frame(height: height) + .offset(y: offset) + + LinearGradient( + gradient: Gradient(stops: [ + .init(color: localAmbientColor.opacity(0.0), location: 0.0), + .init(color: localAmbientColor.opacity(0.1), location: 0.2), + .init(color: localAmbientColor.opacity(0.3), location: 0.7), + .init(color: localAmbientColor.opacity(0.6), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 100) + .clipShape(RoundedRectangle(cornerRadius: 0)) + } + } + .frame(height: headerHeight) + .onAppear { + if let backdropURL = backdropURL, let url = URL(string: backdropURL) { + Task { + let request = ImageRequest(url: url) + if let response = try? await ImagePipeline.shared.image(for: request) { + await MainActor.run { + backdropImage = response + let extractedColor = Color.ambientColor(from: response) + localAmbientColor = extractedColor + } + } + } + } + } + } +} diff --git a/Sora/Views/MediaInfoView/Header/resized.swift b/Sora/Views/MediaInfoView/Header/resized.swift new file mode 100644 index 0000000..f10969c --- /dev/null +++ b/Sora/Views/MediaInfoView/Header/resized.swift @@ -0,0 +1,41 @@ +// +// resized.swift +// Sora +// +// Created by Francesco on 07/08/25. +// + +import UIKit + +extension UIImage { + func resized(to size: CGSize, contentMode: ContentMode = .scaleAspectFit) -> UIImage? { + let aspectSize: CGSize + + switch contentMode { + case .scaleAspectFill: + let aspectRatio = self.size.width / self.size.height + if size.width / aspectRatio > size.height { + aspectSize = CGSize(width: size.width, height: size.width / aspectRatio) + } else { + aspectSize = CGSize(width: size.height * aspectRatio, height: size.height) + } + case .scaleAspectFit: + let aspectRatio = self.size.width / self.size.height + if size.width / aspectRatio < size.height { + aspectSize = CGSize(width: size.width, height: size.width / aspectRatio) + } else { + aspectSize = CGSize(width: size.height * aspectRatio, height: size.height) + } + } + + let renderer = UIGraphicsImageRenderer(size: aspectSize) + return renderer.image { _ in + self.draw(in: CGRect(origin: .zero, size: aspectSize)) + } + } + + enum ContentMode { + case scaleAspectFit + case scaleAspectFill + } +} diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index c3914ef..159c211 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -34,13 +34,10 @@ struct MediaInfoView: View { @State private var tmdbType: TMDBFetcher.MediaType? = nil @State private var currentFetchTask: Task? = nil - // Jikan filler set for this media (passed down to EpisodeCell) @State private var jikanFillerSet: Set? = nil - - // Static/shared Jikan cache & progress guards (one cache for the app to avoid duplicate/expensive fetches) private static var jikanCache: [Int: (fetchedAt: Date, episodes: [JikanEpisode])] = [:] private static let jikanCacheQueue = DispatchQueue(label: "sora.jikan.cache.queue", attributes: .concurrent) - private static let jikanCacheTTL: TimeInterval = 60 * 60 * 24 * 7 // 1 week + private static let jikanCacheTTL: TimeInterval = 60 * 60 * 24 * 7 private static var inProgressMALIDs: Set = [] private static let inProgressQueue = DispatchQueue(label: "sora.jikan.inprogress.queue") @@ -297,37 +294,15 @@ struct MediaInfoView: View { contentContainer } } - .onAppear { - UIScrollView.appearance().bounces = false - } } @ViewBuilder private var heroImageSection: some View { - LazyImage(url: URL(string: imageUrl)) { state in - if let uiImage = state.imageContainer?.image { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } else { - Rectangle() - .fill( - LinearGradient( - gradient: Gradient(colors: [ - Color.gray.opacity(0.2), - Color.gray.opacity(0.3), - Color.gray.opacity(0.2) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } - } + StretchyHeaderView( + backdropURL: imageUrl, + headerHeight: 700, + minHeaderHeight: 400 + ) } @ViewBuilder diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index bb702c8..ce00601 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -56,6 +56,9 @@ 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; 131270172DC13A010093AA9C /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131270162DC13A010093AA9C /* DownloadManager.swift */; }; 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; }; + 131BC85B2EC3814700E19F3E /* StretchyHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131BC85A2EC3814700E19F3E /* StretchyHeader.swift */; }; + 131BC85D2EC3824700E19F3E /* AmbientColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131BC85C2EC3824700E19F3E /* AmbientColor.swift */; }; + 131BC85F2EC3828500E19F3E /* resized.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131BC85E2EC3828500E19F3E /* resized.swift */; }; 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; }; 1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; }; 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; }; @@ -187,6 +190,9 @@ 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = ""; }; 131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = ""; }; + 131BC85A2EC3814700E19F3E /* StretchyHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StretchyHeader.swift; sourceTree = ""; }; + 131BC85C2EC3824700E19F3E /* AmbientColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmbientColor.swift; sourceTree = ""; }; + 131BC85E2EC3828500E19F3E /* resized.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = resized.swift; sourceTree = ""; }; 1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = ""; }; 132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = ""; }; @@ -517,6 +523,16 @@ path = SkeletonCells; sourceTree = ""; }; + 131BC8602EC3828700E19F3E /* Header */ = { + isa = PBXGroup; + children = ( + 131BC85E2EC3828500E19F3E /* resized.swift */, + 131BC85A2EC3814700E19F3E /* StretchyHeader.swift */, + 131BC85C2EC3824700E19F3E /* AmbientColor.swift */, + ); + path = Header; + sourceTree = ""; + }; 1327FBA52D758CEA00FC6689 /* Analytics */ = { isa = PBXGroup; children = ( @@ -603,6 +619,7 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( + 131BC8602EC3828700E19F3E /* Header */, 1E0435F02DFCB86800FF6808 /* Matching */, 04536F762E04BA6900A11248 /* ChapterCell */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, @@ -1086,6 +1103,7 @@ 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */, 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, + 131BC85F2EC3828500E19F3E /* resized.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, 047F170A2E0C93E10081B5FB /* AllReading.swift in Sources */, @@ -1108,6 +1126,7 @@ 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */, 13627FE32E6894970062CAD5 /* JSController-NetworkFetch.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, + 131BC85B2EC3814700E19F3E /* StretchyHeader.swift in Sources */, 13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, @@ -1135,6 +1154,7 @@ 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, + 131BC85D2EC3824700E19F3E /* AmbientColor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */, 7260B66D2E32A8CB00365CDA /* OrphanedDownloadsView.swift in Sources */,