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
37 changes: 22 additions & 15 deletions App/Continuity/Views/MiniPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
44 changes: 30 additions & 14 deletions App/Continuity/Views/NowPlayingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
72 changes: 52 additions & 20 deletions App/Continuity/Views/Theme.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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<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
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
Expand Down
Loading