Added StretchyHeader from Luna

This commit is contained in:
cranci1 2025-11-11 15:48:04 +01:00
parent 32be5059e4
commit e27de30bae
5 changed files with 311 additions and 31 deletions

View file

@ -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..<height {
for x in 0..<width {
let pixelIndex = (y * width + x) * bytesPerPixel
let red = Float(buffer[pixelIndex]) / 255.0
let green = Float(buffer[pixelIndex + 1]) / 255.0
let blue = Float(buffer[pixelIndex + 2]) / 255.0
let alpha = Float(buffer[pixelIndex + 3]) / 255.0
guard alpha > 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)
}
}
}

View file

@ -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
}
}
}
}
}
}
}

View file

@ -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
}
}

View file

@ -34,13 +34,10 @@ struct MediaInfoView: View {
@State private var tmdbType: TMDBFetcher.MediaType? = nil
@State private var currentFetchTask: Task<Void, Never>? = nil
// Jikan filler set for this media (passed down to EpisodeCell)
@State private var jikanFillerSet: Set<Int>? = 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<Int> = []
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

View file

@ -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 = "<group>"; };
131270162DC13A010093AA9C /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
131BC85A2EC3814700E19F3E /* StretchyHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StretchyHeader.swift; sourceTree = "<group>"; };
131BC85C2EC3824700E19F3E /* AmbientColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmbientColor.swift; sourceTree = "<group>"; };
131BC85E2EC3828500E19F3E /* resized.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = resized.swift; sourceTree = "<group>"; };
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
@ -517,6 +523,16 @@
path = SkeletonCells;
sourceTree = "<group>";
};
131BC8602EC3828700E19F3E /* Header */ = {
isa = PBXGroup;
children = (
131BC85E2EC3828500E19F3E /* resized.swift */,
131BC85A2EC3814700E19F3E /* StretchyHeader.swift */,
131BC85C2EC3824700E19F3E /* AmbientColor.swift */,
);
path = Header;
sourceTree = "<group>";
};
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 */,