diff --git a/App/Continuity/Views/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index 98b4448..4d7a5ba 100644 --- a/App/Continuity/Views/MainPagerView.swift +++ b/App/Continuity/Views/MainPagerView.swift @@ -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) @@ -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) + } } } @@ -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 diff --git a/App/Continuity/Views/Theme.swift b/App/Continuity/Views/Theme.swift index f0fb314..50869f7 100644 --- a/App/Continuity/Views/Theme.swift +++ b/App/Continuity/Views/Theme.swift @@ -5,8 +5,10 @@ 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)], @@ -14,8 +16,12 @@ enum Theme { [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. diff --git a/App/Continuity/Views/UpNextView.swift b/App/Continuity/Views/UpNextView.swift index 5f95ba5..284190e 100644 --- a/App/Continuity/Views/UpNextView.swift +++ b/App/Continuity/Views/UpNextView.swift @@ -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.