mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-14 05:20:25 +00:00
Added StretchyHeader from Luna
This commit is contained in:
parent
32be5059e4
commit
e27de30bae
5 changed files with 311 additions and 31 deletions
160
Sora/Views/MediaInfoView/Header/AmbientColor.swift
Normal file
160
Sora/Views/MediaInfoView/Header/AmbientColor.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Sora/Views/MediaInfoView/Header/StretchyHeader.swift
Normal file
84
Sora/Views/MediaInfoView/Header/StretchyHeader.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sora/Views/MediaInfoView/Header/resized.swift
Normal file
41
Sora/Views/MediaInfoView/Header/resized.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||
Loading…
Reference in a new issue