diff --git a/App/Continuity/Views/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index 8a73242..9880cad 100644 --- a/App/Continuity/Views/MainPagerView.swift +++ b/App/Continuity/Views/MainPagerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Playback /// Vertical three-page shell: Library ↑, Now Playing (home), Up Next ↓. /// Sticky paging — one full screen at a time via `scrollTargetBehavior(.paging)`. @@ -6,22 +7,19 @@ import SwiftUI /// their own scroll; jump home via the mini player, chevrons, or starting playback. struct MainPagerView: View { @State private var pagerState = MainPagerState() + @Environment(Player.self) private var player var body: some View { // Full-screen pages: the pager ignores the safe area so each page tiles exactly one - // screen (no neighbor bleed into the status-bar / home-indicator bands), and the real - // insets are re-injected per page with safeAreaPadding — *additive* safe area, so nav - // bars, lists, and chevrons inset correctly while full-bleed layers (AlbumBackdrop's - // ignoresSafeArea) still escape to the screen edges. + // screen (no neighbor bleed into the status-bar / home-indicator bands); the measured + // insets are re-applied per page below. GeometryReader { proxy in let insets = proxy.safeAreaInsets ScrollView(.vertical) { - // Two inset strategies on purpose: the NavigationStack pages need HARD padding — - // their bars position from the real window safe area, which this full-screen - // pager ignores, so additive safeAreaPadding leaves the title/toolbar under the - // status bar. Now Playing keeps safeAreaPadding: its content is pure SwiftUI - // (chevrons/labels respect it) and its backdrop must escape to full bleed via - // ignoresSafeArea, which hard padding would block. + // Every page gets HARD insets (the pager hides the window safe area, and + // neither NavigationStack bars nor safe-area piercing behave inside scroll + // content); full-bleed surfaces are provided as page BACKGROUNDS behind the + // padding, which cover the whole page frame including the bars. VStack(spacing: 0) { // background AFTER the padding: it fills the whole page frame, so the // status-bar / home-indicator bands show the page's own surface color @@ -32,8 +30,18 @@ struct MainPagerView: View { .background(Color(uiColor: .systemGroupedBackground)) .containerRelativeFrame(.vertical) .id(MainPagerState.Page.library) + // Backdrop as a page BACKGROUND behind the padding: backgrounds cover the + // full padded frame, so the blurred art fills the physical screen — + // including the status-bar and home-indicator bands — with no reliance on + // safe-area piercing (which doesn't survive inside scroll content; it left + // a black band up top). Content keeps additive insets for the chevrons. NowPlayingView(mode: .home) - .safeAreaPadding(insets) + .padding(.top, insets.top) + .padding(.bottom, insets.bottom) + .background { + AlbumBackdrop(url: player.currentTrack?.artworkURL, + seed: player.currentTrack?.gradientSeed ?? 0) + } .containerRelativeFrame(.vertical) .id(MainPagerState.Page.nowPlaying) UpNextView() diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 976a780..bf0bd46 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -37,9 +37,9 @@ struct NowPlayingView: View { // MARK: Home layout (minimal root) private var homeLayout: some View { + // No backdrop here: in home mode MainPagerView supplies AlbumBackdrop as the page + // BACKGROUND (behind the safe-area padding) so the blur fills the physical screen. ZStack { - backdrop - // Title/artist + transport, as one vertically-centred column. VStack(spacing: 34) { trackLabel diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index bc2d10a..a641f34 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import CoreImage /// Lightweight visual helpers: deterministic artwork gradients from a seed, time formatting, /// and a centralised Liquid Glass modifier so the iOS 26 API lives in exactly one place. @@ -33,91 +34,116 @@ extension View { } } -/// Immersive full-bleed backdrop built from the current track's album art: heavily blurred and -/// darkened with a depth scrim (top/bottom gradient + centre-luminous vignette) so white content -/// reads on any artwork. Shared by the minimal home and the full Now Playing surface so both -/// feel like the same room. Falls back to the deterministic gradient (demo tracks / no art). +/// Immersive full-bleed backdrop built from the current track's album art — the Apple Music / +/// Spotify treatment: a smooth vertical gradient sampled from the artwork's dominant colors +/// (muted, dark enough for white content), NOT a stretched blurred image. A gradient has no +/// resolution, always fills the screen, and costs nothing per frame (the old live blur was the +/// render churn behind the playback jetsam RCA). Falls back to the deterministic seed gradient +/// (demo tracks / no art). struct AlbumBackdrop: View { let url: URL? let seed: Int - /// Pre-blurred backdrop bitmap for the current URL (nil while loading → gradient shows). - @State private var backdrop: UIImage? + /// [top, bottom] colors sampled from the current URL's artwork (nil while loading). + @State private var palette: [Color]? var body: some View { - // A pre-rendered tiny bitmap upscaled by the compositor, NOT a live `.blur(radius: 60, - // opaque: true)`. The live blur forced a full-screen (~14 MB) offscreen rasterization - // that re-rendered whenever this subtree invalidated — under a playing 20 Hz position - // timer that render churn ramped memory until jetsam (see the OOM RCA). Upscaling a - // ~40 px image is visually equivalent to a radius-60 blur and costs nothing per frame. ZStack { - Theme.gradient(seed: seed) - if let backdrop { - // Color.clear takes exactly the proposed size; the scaledToFill image lives in - // an overlay so its overflow can never widen this view's layout — an - // unconstrained fill image inflates the ZStack past the screen and shoves the - // page's centered content sideways. - Color.clear - .overlay( - Image(uiImage: backdrop) - .resizable() - .interpolation(.high) - .scaledToFill() - ) + if let palette { + LinearGradient(colors: palette, startPoint: .top, endPoint: .bottom) .transition(.opacity) + } else { + Theme.gradient(seed: seed).overlay(Color.black.opacity(0.35)) } } - .animation(.easeInOut(duration: 0.6), value: backdrop) - .overlay(scrim) - .clipped() - .ignoresSafeArea() - .task(id: url) { - guard let url else { backdrop = nil; return } - backdrop = await BackdropRenderer.image(for: url) - } - } - - /// Keeps the luminous centre while darkening the edges — legibility without a muddy flat wash. - private var scrim: some View { - ZStack { - Color.black.opacity(0.26) + .animation(.easeInOut(duration: 0.6), value: palette) + // Soft edge scrim only: the palette is already tone-mapped for legibility; a light + // top/bottom darkening anchors the status bar and Up Next chevron. + .overlay( LinearGradient( - colors: [.black.opacity(0.4), .clear, .black.opacity(0.55)], + colors: [.black.opacity(0.25), .clear, .black.opacity(0.35)], startPoint: .top, endPoint: .bottom ) - RadialGradient( - colors: [.clear, .black.opacity(0.38)], - center: .center, startRadius: 80, endRadius: 560 - ) + ) + .ignoresSafeArea() + .task(id: url) { + guard let url else { palette = nil; return } + palette = await BackdropRenderer.palette(for: url) } } } -/// Renders and caches the tiny pre-blurred backdrop bitmaps for `AlbumBackdrop`. Downscaling -/// artwork to ~40 px and letting the compositor upscale it full-screen looks identical to a -/// heavy gaussian blur while eliminating the per-frame offscreen render entirely. +/// Samples and caches the backdrop gradient palette for `AlbumBackdrop`: fetch the sharpest +/// available artwork tier, average the top and bottom halves (CIAreaAverage), then tone-map +/// both into the muted, dark range the reference apps use — hue preserved, saturation softened, +/// brightness pinned so white text always reads. @MainActor enum BackdropRenderer { - private static let cache = NSCache() - - static func image(for url: URL) async -> UIImage? { - if let hit = cache.object(forKey: url as NSURL) { return hit } - guard let (data, _) = try? await URLSession.shared.data(from: url), - let source = UIImage(data: data) else { return nil } - let rendered = await Task.detached(priority: .userInitiated) { () -> UIImage in - let side: CGFloat = 40 - let format = UIGraphicsImageRendererFormat() - format.scale = 1 - return UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { _ in - // scaledToFill into the square; the extreme downscale is the "blur". - let s = source.size - let scale = max(side / s.width, side / s.height) - let w = s.width * scale, h = s.height * scale - source.draw(in: CGRect(x: (side - w) / 2, y: (side - h) / 2, width: w, height: h)) + private static var cache: [URL: [Color]] = [:] + + static func palette(for url: URL) async -> [Color]? { + if let hit = cache[url] { return hit } + guard let source = await fetchBestArtwork(url) else { return nil } + let colors = await Task.detached(priority: .userInitiated) { samplePalette(source) }.value + guard let colors else { return nil } + cache[url] = colors + return colors + } + + /// YouTube thumbnails come in quality tiers; try the sharper variants first (they 404 for + /// some videos), falling back to the stored URL. + private static func fetchBestArtwork(_ url: URL) async -> UIImage? { + var candidates: [URL] = [] + let raw = url.absoluteString + if raw.contains("/hqdefault") { + for tier in ["/maxresdefault", "/sddefault"] { + if let upgraded = URL(string: raw.replacingOccurrences(of: "/hqdefault", with: tier)) { + candidates.append(upgraded) + } } - }.value - cache.setObject(rendered, forKey: url as NSURL) - return rendered + } + candidates.append(url) + for candidate in candidates { + guard let (data, response) = try? await URLSession.shared.data(from: candidate) else { continue } + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { continue } + if let image = UIImage(data: data) { return image } + } + return nil + } + + /// [top, bottom] gradient colors: average color of the artwork's top and bottom halves, + /// tone-mapped into the dark, slightly muted band the Spotify/Apple Music backdrops live in. + private nonisolated static func samplePalette(_ image: UIImage) -> [Color]? { + guard let ci = CIImage(image: image) else { return nil } + let extent = ci.extent + let topHalf = CGRect(x: extent.minX, y: extent.midY, width: extent.width, height: extent.height / 2) + let bottomHalf = CGRect(x: extent.minX, y: extent.minY, width: extent.width, height: extent.height / 2) + guard let top = averageColor(ci, in: topHalf), + let bottom = averageColor(ci, in: bottomHalf) else { return nil } + return [toneMapped(top, brightness: 0.45), toneMapped(bottom, brightness: 0.14)] + } + + private nonisolated static func averageColor(_ image: CIImage, in rect: CGRect) -> UIColor? { + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [ + kCIInputImageKey: image, + kCIInputExtentKey: CIVector(cgRect: rect), + ]), let output = filter.outputImage else { return nil } + var bitmap = [UInt8](repeating: 0, count: 4) + let context = CIContext(options: [.workingColorSpace: NSNull()]) + context.render(output, toBitmap: &bitmap, rowBytes: 4, + bounds: CGRect(x: 0, y: 0, width: 1, height: 1), + format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB()) + return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, + blue: CGFloat(bitmap[2]) / 255, alpha: 1) + } + + /// Keep the hue, soften the saturation, and PIN the brightness — sampled art can be + /// near-white or near-black, and the backdrop must stay in a band where white content is + /// always legible and the gradient always reads as "colored dark", never washed out. + private nonisolated static func toneMapped(_ color: UIColor, brightness: CGFloat) -> Color { + var hue: CGFloat = 0, saturation: CGFloat = 0, currentBrightness: CGFloat = 0, alpha: CGFloat = 0 + color.getHue(&hue, saturation: &saturation, brightness: ¤tBrightness, alpha: &alpha) + return Color(hue: hue, saturation: min(saturation * 0.9, 0.55), brightness: brightness) } }