diff --git a/App/Continuity/Views/MainPagerView.swift b/App/Continuity/Views/MainPagerView.swift index 9880cad..a09192b 100644 --- a/App/Continuity/Views/MainPagerView.swift +++ b/App/Continuity/Views/MainPagerView.swift @@ -15,6 +15,7 @@ struct MainPagerView: View { // insets are re-applied per page below. GeometryReader { proxy in let insets = proxy.safeAreaInsets + let pageHeight = proxy.size.height ScrollView(.vertical) { // Every page gets HARD insets (the pager hides the window safe area, and // neither NavigationStack bars nor safe-area piercing behave inside scroll @@ -39,8 +40,15 @@ struct MainPagerView: View { .padding(.top, insets.top) .padding(.bottom, insets.bottom) .background { - AlbumBackdrop(url: player.currentTrack?.artworkURL, - seed: player.currentTrack?.gradientSeed ?? 0) + // 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) @@ -54,6 +62,15 @@ struct MainPagerView: View { .scrollTargetLayout() } .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). @@ -78,6 +95,22 @@ 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 + + var body: some View { + // Opacity 0 at center (full gradient) → 1 at either neighbor (exact background match). + Color(uiColor: .systemGroupedBackground) + .opacity(min(1, abs(pagerState.scrollFraction - 1))) + .ignoresSafeArea() + } +} + /// Shared page selection for the vertical shell — injected so library/queue chrome can jump home. @Observable @MainActor @@ -90,6 +123,11 @@ 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