Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions App/Continuity/Views/MainPagerView.swift
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
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)`.
/// From home, scroll up for Library and down for Up Next. Nested library/queue lists keep
/// 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
Expand All @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions App/Continuity/Views/NowPlayingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 55 additions & 20 deletions App/Continuity/Views/Theme.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<NSURL, UIImage>()

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.
Expand Down
Loading