From 12539d9f6852ef8dc139cf5f5980385898fb1561 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:08:25 +0000 Subject: [PATCH] Stop 20 Hz playback ticks from re-rendering the full-screen backdrop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jetsam RCA's second finding: Player.tick() writes position at 20 Hz and NowPlayingView's body read it (progress ring, scrubber), so the whole page — including AlbumBackdrop's full-screen .blur(radius:60, opaque:true), a ~14 MB offscreen rasterization with an implicit 0.6 s crossfade — re-evaluated twenty times a second while playing. Two changes: - All player.position reads move into tiny leaf views (TrackProgressRing, ScrubberBar, MiniProgressLine); @Observable tracks per-body, so the ticks now invalidate only those leaves. - AlbumBackdrop no longer live-blurs: artwork is downscaled once to a ~40 px bitmap (cached per URL) and upscaled by the compositor — visually identical to the radius-60 blur, zero per-frame offscreen rendering; track changes crossfade between two cached bitmaps. --- App/Continuity/Views/MiniPlayerView.swift | 37 +++++++----- App/Continuity/Views/NowPlayingView.swift | 44 +++++++++----- App/Continuity/Views/Theme.swift | 72 ++++++++++++++++------- 3 files changed, 104 insertions(+), 49 deletions(-) diff --git a/App/Continuity/Views/MiniPlayerView.swift b/App/Continuity/Views/MiniPlayerView.swift index 75bd185..630d2f8 100644 --- a/App/Continuity/Views/MiniPlayerView.swift +++ b/App/Continuity/Views/MiniPlayerView.swift @@ -5,11 +5,6 @@ import Playback struct MiniPlayerView: View { @Environment(Player.self) private var player - private var progress: Double { - guard player.duration > 0 else { return 0 } - return min(max(player.position / player.duration, 0), 1) - } - var body: some View { HStack(spacing: 12) { if let track = player.currentTrack { @@ -45,16 +40,28 @@ struct MiniPlayerView: View { .continuityGlass(cornerRadius: 18) // Thin play-progress line hugging the bottom edge of the glass bar. .overlay(alignment: .bottomLeading) { - GeometryReader { geo in - Capsule() - .fill(Color.accentColor) - .frame(width: max(0, geo.size.width * progress), height: 2.5) - .frame(maxHeight: .infinity, alignment: .bottom) - } - .frame(height: 2.5) - .padding(.horizontal, 14) - .padding(.bottom, 4) - .allowsHitTesting(false) + MiniProgressLine() + } + } +} + +/// Leaf view: the mini player's only `player.position` reader, so the 20 Hz playback ticks +/// re-evaluate just this line — not the whole bar (artwork row included) inside the +/// always-mounted library page. +private struct MiniProgressLine: View { + @Environment(Player.self) private var player + + var body: some View { + let progress = player.duration > 0 ? min(max(player.position / player.duration, 0), 1) : 0 + GeometryReader { geo in + Capsule() + .fill(Color.accentColor) + .frame(width: max(0, geo.size.width * progress), height: 2.5) + .frame(maxHeight: .infinity, alignment: .bottom) } + .frame(height: 2.5) + .padding(.horizontal, 14) + .padding(.bottom, 4) + .allowsHitTesting(false) } } diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 7b09ccb..976a780 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -15,9 +15,6 @@ struct NowPlayingView: View { @Environment(Player.self) private var player @Environment(MainPagerState.self) private var pagerState - // Sheet-mode scrubber state. - @State private var isEditing = false - @State private var scrubValue: Double = 0 @State private var showingTransitionSettings = false // Sheet mode opens the queue as a sheet; home uses the vertical pager instead. @State private var showingUpNext = false @@ -188,12 +185,6 @@ struct NowPlayingView: View { // MARK: Transport - /// How far through the track we are (0…1), drives the home ring around Play. - private var progress: Double { - guard player.duration > 0 else { return 0 } - return min(max(player.position / player.duration, 0), 1) - } - /// One transport, two densities: home = bare 60pt glyphs around the big ring disc, skip /// budget as a pill under Next; sheet = title glyphs around the compact disc, skip budget /// as a count below. The accent disc and skip-budget wiring are shared. @@ -264,11 +255,7 @@ struct NowPlayingView: View { ZStack { Circle() .stroke(.white.opacity(0.16), lineWidth: 3) - Circle() - .trim(from: 0, to: progress) - .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 0.25), value: progress) + TrackProgressRing() Circle() .fill(discGradient) .padding(9) @@ -422,6 +409,35 @@ struct NowPlayingView: View { // MARK: Scrubber (sheet only) private var scrubber: some View { + ScrubberBar() + } +} + +// MARK: - 20 Hz leaf views + +/// These leaves are the ONLY readers of `player.position` on this screen. @Observable tracks +/// dependencies per view body, so confining the 20 Hz playback ticks to these tiny bodies keeps +/// the rest of the page — including the full-screen backdrop — from re-evaluating twenty times +/// a second (the render churn identified in the playback jetsam RCA). +private struct TrackProgressRing: View { + @Environment(Player.self) private var player + + var body: some View { + let progress = player.duration > 0 ? min(max(player.position / player.duration, 0), 1) : 0 + Circle() + .trim(from: 0, to: progress) + .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 0.25), value: progress) + } +} + +private struct ScrubberBar: View { + @Environment(Player.self) private var player + @State private var isEditing = false + @State private var scrubValue: Double = 0 + + var body: some View { VStack(spacing: 4) { Slider( value: Binding( diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index 14d5d2a..7013dd8 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit /// 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. @@ -40,29 +41,32 @@ 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? + var body: some View { - GeometryReader { proxy in - artwork - .frame(width: proxy.size.width, height: proxy.size.height) - .clipped() - .blur(radius: 60, opaque: true) - .overlay(scrim) - .animation(.easeInOut(duration: 0.6), value: url) + // 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 { + Image(uiImage: backdrop) + .resizable() + .interpolation(.high) + .scaledToFill() + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.6), value: backdrop) + .overlay(scrim) + .clipped() .ignoresSafeArea() - } - - @ViewBuilder private var artwork: some View { - if let url { - AsyncImage(url: url) { phase in - if let image = phase.image { - image.resizable().scaledToFill() - } else { - Theme.gradient(seed: seed) - } - } - } else { - Theme.gradient(seed: seed) + .task(id: url) { + guard let url else { backdrop = nil; return } + backdrop = await BackdropRenderer.image(for: url) } } @@ -82,6 +86,34 @@ 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. +@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 + cache.setObject(rendered, forKey: url as NSURL) + return rendered + } +} + /// Reusable square artwork tile (gradient + SF Symbol) used by cards, rows and Now Playing. struct ArtworkView: View { let symbol: String