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
156 changes: 91 additions & 65 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 @@ -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<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))
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: &currentBrightness, alpha: &alpha)
return Color(hue: hue, saturation: min(saturation * 0.9, 0.55), brightness: brightness)
}
}

Expand Down
Loading