diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index ed935f4..e592817 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -5,6 +5,12 @@ import androidx.activity.compose.PredictiveBackHandler import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalDensity import kotlin.coroutines.cancellation.CancellationException import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -174,8 +180,12 @@ internal fun TitleScreen( ) { val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp) val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f) + val list = rememberLazyListState() + var mood by remember(title.key) { mutableStateOf(null) } + val veil = moodVeil(mood) LazyColumn( modifier = Modifier.fillMaxSize(), + state = list, contentPadding = PaddingValues(bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -183,26 +193,27 @@ internal fun TitleScreen( // Edge to edge, fading into the page: the art is the first thing // the page says, and a framed thumbnail with a margin round it read // as one card among the many below it. - Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight)) { - Backdrop(title, Modifier.fillMaxSize()) + Box(Modifier.sharedArt(title.key).fillMaxWidth().height(backdropHeight).clipToBounds()) { + // At half the speed of the page, so the picture stays + // behind the page rather than being part of it. + Backdrop( + title, + Modifier.fillMaxSize().graphicsLayer { + translationY = if (list.firstVisibleItemIndex == 0) list.firstVisibleItemScrollOffset * PARALLAX else 0f + }, + onMood = { mood = it }, + ) Box( Modifier.fillMaxSize().background( Brush.verticalGradient( - 0.45f to Color.Transparent, + 0.4f to Color.Transparent, + // The picture's own colour on the way into the + // page, as on the library's banner. + 0.75f to veil.copy(alpha = 0.55f), 1f to MaterialTheme.colorScheme.background, ), ), ) - IconButton( - onClick = onBack, - modifier = Modifier - .statusBarsPadding() - .padding(8.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.4f)), - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White) - } } } item(key = "heading") { @@ -439,6 +450,7 @@ internal fun TitleScreen( } } } + TitleBar(title.displayName, list, with(LocalDensity.current) { backdropHeight.toPx() }, onBack) } if (showMatch) { ChangeMatchDialog( @@ -450,6 +462,62 @@ internal fun TitleScreen( } } +/** How fast the backdrop moves against the page scrolling over it. */ +private const val PARALLAX = 0.5f + +/** + * The bar over the title page: only the back button while the backdrop is on + * screen, then — once the backdrop has scrolled away — a solid bar with the + * title's name, so the page still says whose episodes these are. + */ +@Composable +private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack: () -> Unit) { + val reach = with(LocalDensity.current) { TITLE_BAR.toPx() } + val solid by remember(list, backdrop) { + derivedStateOf { + if (list.firstVisibleItemIndex > 0) { + 1f + } else { + ((list.firstVisibleItemScrollOffset - (backdrop - 2 * reach)) / reach).coerceIn(0f, 1f) + } + } + } + val scheme = MaterialTheme.colorScheme + Row( + Modifier + .fillMaxWidth() + .background(scheme.surfaceContainer.copy(alpha = solid)) + .statusBarsPadding() + .height(TITLE_BAR), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .padding(8.dp) + .clip(CircleShape) + // A disc behind the arrow over the picture, gone once the bar + // is there to sit on. + .background(Color.Black.copy(alpha = 0.4f * (1f - solid))), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back to library", + tint = lerp(Color.White, scheme.onSurface, solid), + ) + } + Text( + name, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(end = 16.dp).graphicsLayer { alpha = solid }, + ) + } +} + +private val TITLE_BAR = 56.dp + /** * One part of the franchise: its cover, what it is and when. This title's own * is marked rather than left out, so the row says where it sits in the story. diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 645cbfa..86e74af 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 2368c35..7118b4b 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 8412565..7e101e6 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/docs/IOS.md b/docs/IOS.md index 4dd71ab..3e28a9c 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -90,6 +90,10 @@ Each of these is the platform's doing, not a choice to diverge. a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`) stand in for the ripple, and a settings page goes back by an edge strip of its own, as a title page does, where Android has the system back. +- **No zoom into a title page.** iOS 18's `.navigationTransition(.zoom)` only + runs inside a `NavigationStack`, and the title page is shown over the library + by hand, so it keeps its slide in from the edge — Android's shared poster has + no counterpart here. - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share secrets are this-device-only Keychain items, so a restored share list would arrive without them, and a restored `sync.json` would give two devices one sync diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 54dcc64..4af02b6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -67,7 +67,9 @@ containers. ## A title's page -Click a title to open its page. **Esc** goes back. +Click a title to open its page. **Esc** goes back. On a phone, the picture at the top slides +away more slowly than the page, and once it has gone a bar with the title's name stays at the +top. - **Resume S01E03 · 12:04** continues where you stopped. **Start over** plays the episode from the beginning. diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 33ca906..9846307 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -3,6 +3,12 @@ import SwiftUI /// How wide the page's text and controls run on a window wider than a phone. private let readableWidth: CGFloat = 720 +/// How fast the backdrop moves against the page scrolling over it. +private let parallax: CGFloat = 0.5 + +/// The bar over the page — Android's `TITLE_BAR`. +private let titleBarHeight: CGFloat = 56 + struct TitleView: View { let title: TitleRecord let onPlay: ([EpisodeRecord], Int) -> Void @@ -22,6 +28,10 @@ struct TitleView: View { // gesture is dragged, and springs back if it is let go of — Android's // predictive back, so the viewer sees where back goes before committing. @State private var backProgress: CGFloat = 0 + /// How far the backdrop has scrolled up, which is how solid the bar is. + @State private var scrolled: CGFloat = 0 + /// The backdrop's colour, once it has arrived — see `pictureMood`. + @State private var mood: UInt32? init( title: TitleRecord, @@ -57,7 +67,7 @@ struct TitleView: View { let backdropHeight = min(geometry.size.width * 9 / 16, geometry.size.height * 0.55) ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - backdrop(height: backdropHeight + geometry.safeAreaInsets.top, top: geometry.safeAreaInsets.top) + backdrop(height: backdropHeight + geometry.safeAreaInsets.top) heading(playlist: playlist, nextUp: nextUp, upNext: upNext) .padding(.horizontal, side + 16) if franchise.count > 1 { @@ -85,7 +95,11 @@ struct TitleView: View { .padding(.bottom, 24) } .ignoresSafeArea(edges: .top) + .coordinateSpace(name: "title") .background(scheme.background) + .overlay(alignment: .top) { + titleBar(solid: min(max((scrolled - (backdropHeight - 2 * titleBarHeight)) / titleBarHeight, 0), 1)) + } } .scaleEffect(1 - 0.1 * backProgress) .clipShape(RoundedRectangle(cornerRadius: 32 * backProgress)) @@ -110,6 +124,7 @@ struct TitleView: View { } .onChange(of: title.key) { overviewOpen = false + mood = nil let playlist = title.playlist let upNext = playlist.indices.contains(nextUpIndex(playlist, continueIndex: title.continueIndex)) ? playlist[nextUpIndex(playlist, continueIndex: title.continueIndex)] : nil seasonIndex = title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0 @@ -117,32 +132,71 @@ struct TitleView: View { } /// Edge to edge, fading into the page: the art is the first thing the page - /// says. - private func backdrop(height: CGFloat, top: CGFloat) -> some View { - Backdrop(title: title) + /// says. It moves at half the speed of the page, so the picture stays + /// behind the page rather than being part of it. + private func backdrop(height: CGFloat) -> some View { + Backdrop(title: title) { mood = $0 } .frame(height: height) .frame(maxWidth: .infinity) + .visualEffect { content, proxy in + content.offset(y: max(-proxy.frame(in: .named("title")).minY, 0) * parallax) + } .clipped() .overlay { LinearGradient( - stops: [.init(color: .clear, location: 0.45), .init(color: scheme.background, location: 1)], + stops: [ + .init(color: .clear, location: 0.4), + // The picture's own colour on the way into the page, + // as on the library's banner. + .init(color: scheme.veil(mood).opacity(0.55), location: 0.75), + .init(color: scheme.background, location: 1), + ], startPoint: .top, endPoint: .bottom ) + .animation(Motion.arrive, value: mood) } - .overlay(alignment: .topLeading) { - Button(action: onBack) { - Image(systemName: "chevron.backward") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 40, height: 40) - .background(.black.opacity(0.4), in: Circle()) - .frame(width: 48, height: 48) + .background { + GeometryReader { geometry in + Color.clear.onChange(of: geometry.frame(in: .named("title")).minY) { _, minY in + scrolled = -minY + } } - .accessibilityLabel("Back to library") - .padding(8) - .padding(.top, top) } + // Scrolled far enough to leave the lazy stack: the bar is long + // since solid. + .onDisappear { scrolled = .greatestFiniteMagnitude } + .onAppear { scrolled = 0 } + } + + /// The bar over the page: only the back button while the backdrop is on + /// screen, then a solid bar with the title's name once it has scrolled + /// away, so the page still says whose episodes these are. + private func titleBar(solid: CGFloat) -> some View { + HStack(spacing: 0) { + Button(action: onBack) { + Image(systemName: "chevron.backward") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(solid > 0.5 ? scheme.onSurface : .white) + .frame(width: 40, height: 40) + // A disc behind the arrow over the picture, gone once the + // bar is there to sit on. + .background(.black.opacity(0.4 * (1 - solid)), in: Circle()) + .frame(width: 48, height: 48) + } + .accessibilityLabel("Back to library") + .padding(.horizontal, 8) + Text(title.displayName) + .textStyle(.titleLarge) + .foregroundStyle(scheme.onSurface) + .lineLimit(1) + .opacity(solid) + .padding(.trailing, 16) + Spacer(minLength: 0) + } + .frame(height: titleBarHeight) + .background(scheme.surfaceContainer.opacity(solid).ignoresSafeArea(edges: .top)) + .animation(.easeOut(duration: 0.15), value: solid > 0.5) } @ViewBuilder private func heading(playlist: [EpisodeRecord], nextUp: Int, upNext: EpisodeRecord?) -> some View {