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
137 changes: 71 additions & 66 deletions App/Continuity/Views/MainPagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,60 +22,44 @@ struct MainPagerView: View {
// 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
// instead of the pager's black.
// The pages are transparent: a single shared `PagerBackdrop` behind the whole
// scroll content supplies every page's surface (flat `systemGroupedBackground`
// for Library/Up Next, the album gradient over Now Playing, and the gradient
// bleeding halfway into each neighbor). Because that one layer scrolls with the
// content, the gradient is continuous across page boundaries — no per-page
// seam, no per-frame crossfade state.
LibrarySheetView()
.padding(.top, insets.top)
.padding(.bottom, insets.bottom)
.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)
.padding(.top, insets.top)
.padding(.bottom, insets.bottom)
.background {
// The gradient dissolves into the neighbors' opaque background as
// the user pages, killing the hard seam. The fade overlay is a
// separate leaf view so per-frame `scrollFraction` reads never
// invalidate the heavy backdrop / now-playing bodies (jetsam RCA).
ZStack {
AlbumBackdrop(url: player.currentTrack?.artworkURL,
seed: player.currentTrack?.gradientSeed ?? 0)
NowPlayingBackgroundFade()
}
}
.containerRelativeFrame(.vertical)
.id(MainPagerState.Page.nowPlaying)
UpNextView()
.padding(.top, insets.top)
.padding(.bottom, insets.bottom)
.background(Color(uiColor: .systemGroupedBackground))
.containerRelativeFrame(.vertical)
.id(MainPagerState.Page.upNext)
}
.scrollTargetLayout()
// One shared backdrop behind all three pages, sized to the full content height
// (three pages). Static and position-based, so it needs no per-scroll-frame state.
.background {
PagerBackdrop(url: player.currentTrack?.artworkURL,
seed: player.currentTrack?.gradientSeed ?? 0,
pageHeight: pageHeight)
}
}
.scrollTargetBehavior(.paging)
// Continuous scroll signal for the now-playing → neighbor background crossfade.
// 1.0 = now-playing centered, 0.0 = library, 2.0 = up-next. Only the tiny
// `NowPlayingBackgroundFade` leaf reads this, so heavy views don't re-render per frame.
.onScrollGeometryChange(for: CGFloat.self) { geo in
geo.contentOffset.y
} action: { _, offsetY in
guard pageHeight > 0 else { return }
pagerState.scrollFraction = offsetY / pageHeight
}
.scrollPosition(id: pageBinding)
.scrollIndicators(.hidden)
// Three equal pages → center lands on Now Playing (the home screen).
.defaultScrollAnchor(.center)
.background(Color.black)
// Matches the backdrop's flat fill so overscroll doesn't flash black in light mode.
.background(Color(uiColor: .systemGroupedBackground))
.ignoresSafeArea()
}
.environment(pagerState)
Expand All @@ -95,38 +79,64 @@ struct MainPagerView: View {
}
}

/// Leaf overlay that dissolves the now-playing album gradient into the neighbors' opaque
/// `systemGroupedBackground` as the user pages, so there's no hard seam. Isolated as its own
/// `View` reading `MainPagerState.scrollFraction` from the environment: because the state is
/// `@Observable`, only this tiny view re-evaluates per scroll frame — the `AlbumBackdrop` and
/// `NowPlayingView` never see `scrollFraction` and so aren't invalidated (jetsam RCA).
private struct NowPlayingBackgroundFade: View {
@Environment(MainPagerState.self) private var pagerState
/// Single shared backdrop behind the whole three-page scroll content. It renders the
/// now-playing album gradient at full strength over the center page and bleeds a muted band of
/// the gradient's edge colors halfway into the Library (above) and Up Next (below) pages before
/// settling into the flat `systemGroupedBackground`, so paging between screens never hits a flat
/// wall. Position-based and static: it scrolls WITH the content, so the gradient stays continuous
/// across page boundaries with no seam — and it needs no per-scroll-frame state (the old
/// `scrollFraction` crossfade is gone), keeping heavy views off the per-tick invalidation path.
private struct PagerBackdrop: View {
let url: URL?
let seed: Int
/// One page's height; the backdrop spans three of these (Library, Now Playing, Up Next).
let pageHeight: CGFloat

/// Resolved palette for the current artwork; nil until the async render lands. Shares the
/// per-URL cache with `AlbumBackdrop`, so resolving it here is a cheap cache hit after the
/// first fetch — and yields the exact same edge colors the backdrop draws.
@State private var style: BackdropStyle?

var body: some View {
// Distance from the now-playing page center: 0 at rest (full gradient), 1 at a neighbor.
let progress = min(1, abs(pagerState.scrollFraction - 1))
// The revealed neighbor sits ABOVE when paging to Library (fraction < 1) and BELOW when
// paging to Up Next — so the neighbor's opaque background abuts the now-playing page at
// its top or bottom edge respectively. A *uniform* tint (the old approach) darkened the
// whole backdrop evenly, leaving a crisp step at that page boundary: half-darkened
// gradient meeting the neighbor's solid fill. Instead dissolve the backdrop into the
// neighbor's background from the abutting edge inward, as a soft vertical ramp — the
// boundary is background-on-background (no seam) and the fall-off into the gradient is
// continuous (no crisp internal line). At rest (progress 0) every stop is clear, so the
// full artwork gradient shows untouched.
let towardLibrary = pagerState.scrollFraction < 1
let neighbor = Color(uiColor: .systemGroupedBackground)
LinearGradient(
stops: [
.init(color: neighbor.opacity(progress), location: 0),
.init(color: neighbor.opacity(progress * 0.5), location: 0.28),
.init(color: .clear, location: 0.6),
],
startPoint: towardLibrary ? .top : .bottom,
endPoint: towardLibrary ? .bottom : .top
)
.ignoresSafeArea()
let flat = Color(uiColor: .systemGroupedBackground)
// Edge colors the neighbor extensions continue from the now-playing gradient. While the
// artwork palette is still resolving (or absent, for demo tracks), fall back to the
// deterministic seed palette — the same source `AlbumBackdrop` falls back to — so the
// bleed is ALWAYS present and roughly matches, never a flat gap that pops when art lands.
let palette = Theme.gradientColors(seed: seed)
let topEdge = style?.colors.first ?? palette.first ?? flat
let bottomEdge = style?.colors.last ?? palette.last ?? flat

// Tuned for dark mode (primary usage): the extensions are muted edge colors fading out by
// each neighbor's midpoint, so library/queue text stays legible over them.
// Light mode may need tuning — the systemGroupedBackground is near-white there.
ZStack(alignment: .top) {
// Base flat fill across the full content height (the far halves of the neighbors).
flat

// Top extension into the Library page's bottom half [0.5h, 1.0h]: flat at the Library
// midpoint continuing up to the gradient's TOP edge color at the page boundary.
LinearGradient(colors: [flat, topEdge], startPoint: .top, endPoint: .bottom)
.frame(height: pageHeight * 0.5)
.offset(y: pageHeight * 0.5)

// Now-playing gradient at FULL strength over the center page [1.0h, 2.0h] — reuses
// `AlbumBackdrop`, so the now-playing look is unchanged from before.
AlbumBackdrop(url: url, seed: seed)
.frame(height: pageHeight)
.offset(y: pageHeight)

// Bottom extension into the Up Next page's top half [2.0h, 2.5h]: the gradient's
// BOTTOM edge color at the page boundary fading to flat by the Up Next midpoint.
LinearGradient(colors: [bottomEdge, flat], startPoint: .top, endPoint: .bottom)
.frame(height: pageHeight * 0.5)
.offset(y: pageHeight * 2)
}
.frame(height: pageHeight * 3, alignment: .top)
.task(id: url) {
guard let url else { style = nil; return }
style = await BackdropRenderer.style(for: url)
}
}
}

Expand All @@ -142,11 +152,6 @@ final class MainPagerState {

var page: Page = .nowPlaying

/// Continuous vertical scroll position of the pager, in pages: 1.0 = now-playing centered,
/// 0.0 = library fully shown, 2.0 = up-next fully shown. Updated every scroll frame; read
/// only by `NowPlayingBackgroundFade` so heavier views aren't invalidated per tick.
var scrollFraction: CGFloat = 1

func goToNowPlaying() {
withAnimation(.snappy(duration: 0.35)) {
page = .nowPlaying
Expand Down
14 changes: 10 additions & 4 deletions App/Continuity/Views/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,23 @@ 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.
enum Theme {
/// A pleasant two/three-stop gradient derived deterministically from a seed.
static func gradient(seed: Int) -> LinearGradient {
/// The deterministic seed palette as raw `[top, bottom]` colors. Exposed so callers that
/// need the individual edge colors (e.g. the pager backdrop's bleed fallback before the
/// artwork palette resolves) share the exact same source as `gradient(seed:)`.
static func gradientColors(seed: Int) -> [Color] {
let palettes: [[Color]] = [
[Color(red: 0.45, green: 0.26, blue: 0.90), Color(red: 0.92, green: 0.32, blue: 0.62)],
[Color(red: 0.10, green: 0.52, blue: 0.86), Color(red: 0.16, green: 0.82, blue: 0.74)],
[Color(red: 0.95, green: 0.45, blue: 0.20), Color(red: 0.92, green: 0.74, blue: 0.20)],
[Color(red: 0.18, green: 0.20, blue: 0.34), Color(red: 0.40, green: 0.46, blue: 0.66)],
[Color(red: 0.86, green: 0.20, blue: 0.40), Color(red: 0.36, green: 0.16, blue: 0.52)],
]
let colors = palettes[abs(seed) % palettes.count]
return LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
return palettes[abs(seed) % palettes.count]
}

/// A pleasant two/three-stop gradient derived deterministically from a seed.
static func gradient(seed: Int) -> LinearGradient {
LinearGradient(colors: gradientColors(seed: seed), startPoint: .topLeading, endPoint: .bottomTrailing)
}

/// mm:ss formatting for the transport clock.
Expand Down
3 changes: 3 additions & 0 deletions App/Continuity/Views/UpNextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ struct UpNextView: View {
}
}
.listStyle(.insetGrouped)
// Let the shared pager backdrop (the gradient bleeding up from Now Playing) show through
// the list; the inset rows stay opaque so queue content remains legible.
.scrollContentBackground(.hidden)
}

/// Same 44pt artwork + title/artist treatment as the library's track rows.
Expand Down
Loading