From 1acea5fc88b99d56447d406c333ad392406cf278 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:59:58 +0000 Subject: [PATCH] Higher-quality full-bleed Now Playing backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three regressions from the cached-backdrop change, fixed together: - The blur looked blocky: 40 px nearest-tier upscale was too coarse. BackdropRenderer now works on a 160 px square with a real clamped gaussian (sigma 20), matching the old live radius-60 look — still a one-time render per URL, zero per-frame cost. - The source art was soft: the renderer now tries maxresdefault and sddefault before falling back to the stored hqdefault thumbnail. - The blur didn't fill the screen (black band at the top): safe-area piercing doesn't survive inside scroll content, so the backdrop now lives as the Now Playing page's BACKGROUND behind hard insets in MainPagerView — backgrounds cover the full padded frame, bars included. homeLayout no longer embeds its own backdrop. --- App/Continuity/Views/MainPagerView.swift | 30 +++++---- App/Continuity/Views/NowPlayingView.swift | 4 +- App/Continuity/Views/Theme.swift | 75 +++++++++++++++++------ 3 files changed, 76 insertions(+), 33 deletions(-) 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..fdcddcf 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. @@ -45,11 +46,12 @@ struct AlbumBackdrop: View { @State private var backdrop: UIImage? var body: some View { - // A pre-rendered tiny bitmap upscaled by the compositor, NOT a live `.blur(radius: 60, + // A pre-blurred 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. + // timer that render churn ramped memory until jetsam (see the OOM RCA). The bitmap is + // rendered once per URL by BackdropRenderer (real gaussian on a 160 px working image), + // so it matches the old blur's look at zero per-frame cost. ZStack { Theme.gradient(seed: seed) if let backdrop { @@ -93,32 +95,65 @@ struct AlbumBackdrop: View { } } -/// 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. +/// Renders and caches the pre-blurred backdrop bitmaps for `AlbumBackdrop`. A ONE-TIME render +/// per URL (cached): fetch the sharpest available artwork, scale-fill it into a small working +/// square, run a real gaussian pass, and let the compositor upscale the result full-screen. +/// Matches the look of the old live `.blur(radius: 60)` with zero per-frame offscreen rendering +/// (the live blur was the render churn behind the playback jetsam RCA). @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)) - } - }.value + guard let source = await fetchBestArtwork(url) else { return nil } + let rendered = await Task.detached(priority: .userInitiated) { render(source) }.value cache.setObject(rendered, forKey: url as NSURL) return rendered } + + /// YouTube thumbnails come in quality tiers; try the sharper variants first (they 404 for + /// some videos), falling back to the stored URL. A sharper source keeps the blurred field + /// smooth instead of blocky. + 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) + } + } + } + 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 + } + + /// Scale-fill into a 160 px square, then a real gaussian (clamped so edges don't darken). + /// sigma 20 at 160 px upscaled to screen width ≈ the old radius-60 live blur. + private nonisolated static func render(_ source: UIImage) -> UIImage { + let side: CGFloat = 160 + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let small = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format).image { _ in + 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)) + } + guard let ci = CIImage(image: small) else { return small } + let blurred = ci.clampedToExtent() + .applyingGaussianBlur(sigma: 20) + .cropped(to: ci.extent) + let context = CIContext(options: nil) + guard let cg = context.createCGImage(blurred, from: blurred.extent) else { return small } + return UIImage(cgImage: cg) + } } /// Reusable square artwork tile (gradient + SF Symbol) used by cards, rows and Now Playing.