Skip to content
Merged
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
42 changes: 40 additions & 2 deletions App/Continuity/Views/MainPagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading