From 74b737fb52bfbb2cc7afa0b0288ae59e67232320 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 00:12:17 +0100 Subject: [PATCH 01/17] Add the shared polish tokens and motion helpers --- wolfskin-design/shell-tokens.json | 1 + .../wolfskin/design/FocusRingHost.kt | 10 +++ .../wolfskin/design/PlaybackHandoff.kt | 53 +++++++++++++ .../wolfskin/design/PolishMotion.kt | 75 +++++++++++++++++++ .../wolfskin/design/RecoveryToast.kt | 31 +++++++- .../wolfskin/design/WolfskinTokens.kt | 3 + wolfskin-design/tokens.css | 3 + wolfskin-design/tokens.json | 2 + wolfskin-design/tokens.sha256 | 8 +- 9 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c29b7ae..47a40ac2 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -30,6 +30,7 @@ "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, + "thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" }, "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt new file mode 100644 index 00000000..6175d6f9 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable + +/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */ +@Composable +fun FocusRingHost(content: @Composable () -> Unit) { + content() +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt new file mode 100644 index 00000000..197cea7e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M + +/** One pending playback start per surface; the player opens only after the page has faded to black. */ +@Stable +class PlaybackStart { + /** The control that reads "Starting…" while the stream is prepared, or null. */ + var starting by mutableStateOf(null) + private set + + /** Set once the player is ready; the veil runs it exactly once, when the screen is black. */ + var launch by mutableStateOf<(() -> Unit)?>(null) + private set + + fun begin(control: String) { starting = control; launch = null } + fun fail() { starting = null; launch = null } + fun ready(open: () -> Unit) { launch = open } + internal fun finish() { starting = null; launch = null } +} + +/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */ +@Composable +fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) { + val pending = start.launch + val reduced = reducedMotion() + val alpha = remember { Animatable(0f) } + LaunchedEffect(pending, reduced) { + if (pending == null) { + alpha.snapTo(0f) + return@LaunchedEffect + } + alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease)) + start.finish() + pending() + } + if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) }) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt new file mode 100644 index 00000000..393a25e5 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M + +/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */ +@Composable +internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State { + val reduced = reducedMotion() + val progress = remember { Animatable(initial) } + LaunchedEffect(visible, reduced) { + progress.animateTo(if (visible) 1f else 0f, + tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut)) + } + return progress.asState() +} + +/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */ +internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind { + val scrim = WolfskinTokens.Color.sheetScrim + drawRect(scrim.copy(alpha = scrim.alpha * progress())) +} + +/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */ +internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer { + alpha = progress() + compositingStrategy = CompositingStrategy.ModulateAlpha +} + +/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */ +@Composable +internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color { + val reduced = reducedMotion() + return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value +} + +/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */ +@Composable +internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) { + val reduced = reducedMotion() + val progress = remember { Animatable(if (busy) 1f else 0f) } + LaunchedEffect(busy, reduced) { + progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease)) + } + val showLabel = busy || progress.value > 0f + Layout(content = { + Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() } + if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false) + }) { measurables, constraints -> + val resting = measurables[0].measure(constraints) + val label = measurables.getOrNull(1)?.measure(Constraints()) + layout(resting.width, resting.height) { + resting.place(0, 0) + if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt index fac5305f..30e19c61 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -1,14 +1,24 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.DpOffset @@ -16,14 +26,31 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M + +/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */ +@Composable +fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + var last by remember { mutableStateOf(message) } + val opacity = remember { Animatable(0f) } + SideEffect { if (message != null) last = message } + LaunchedEffect(message != null, reduced) { + opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease)) + } + val text = message ?: last ?: return + if (message == null && opacity.value == 0f) return + RecoveryToast(text, modifier) { opacity.value } +} /** Recovery messages remain transient and never take focus from the action that failed. */ @Composable -fun RecoveryToast(message: String, modifier: Modifier = Modifier) { +fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) { Box(modifier.fillMaxSize()) { - val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } + val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop) .widthIn(max = H.toastWidth) + .graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha } .dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity), offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY))) .background(C.surface2, shape) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index c927d9c9..9770b26d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -229,6 +229,8 @@ object WolfskinTokens { const val focusMove = 180 const val captionDelay = 60 const val captionOut = 100 + const val sheetIn = 160 + const val sheetOut = 100 const val artReveal = 160 const val heroFade = 320 const val heroCopyOut = 120 @@ -319,6 +321,7 @@ object WolfskinTokens { val receiptPaddingX = 15.0.dp val receiptGap = 5.0.dp val receiptGlyph = 18.0.dp + val thumbGlyph = 20.0.dp val buttonMargin = 6.0.dp val skeletonTitleWidth = 180.0.dp val skeletonTitleHeight = 17.0.dp diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index b02ec55d..463122e5 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -142,6 +142,8 @@ --motion-focusMove: 180ms; --motion-captionDelay: 60ms; --motion-captionOut: 100ms; + --motion-sheetIn: 160ms; + --motion-sheetOut: 100ms; --motion-artReveal: 160ms; --motion-heroFade: 320ms; --motion-heroCopyOut: 120ms; @@ -192,6 +194,7 @@ --requests-receiptPaddingX: 30.0px; --requests-receiptGap: 10.0px; --requests-receiptGlyph: 36.0px; + --requests-thumbGlyph: 40.0px; --requests-buttonMargin: 12.0px; --requests-skeletonTitleWidth: 360.0px; --requests-skeletonTitleHeight: 34.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index b04160f1..f84690cc 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1408,6 +1408,8 @@ "focusMove": { "$type": "duration", "$value": "180ms" }, "captionDelay": { "$type": "duration", "$value": "60ms" }, "captionOut": { "$type": "duration", "$value": "100ms" }, + "sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" }, + "sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" }, "artReveal": { "$type": "duration", "$value": "160ms" }, "heroFade": { "$type": "duration", "$value": "320ms" }, "heroCopyOut": { "$type": "duration", "$value": "120ms" }, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index d3684c03..59039c0f 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,9 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json 993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json -192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json +68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json -731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css -12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json +e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css +e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json 19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json -- 2.49.1 From 2572da00fde67194019308e137d4f75bb3e3f65f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 01:56:06 +0100 Subject: [PATCH 02/17] Polish Requests transitions and shell hosts --- .../wolfskin/design/ComingInSheet.kt | 173 ++++++++-- .../wolfskin/design/ShellState.kt | 10 +- .../wolfskin/design/ShellSurface.kt | 8 +- .../wolfskin/design/RequestsKeysTest.kt | 4 +- .../wolfskin/design/RequestsPolishTest.kt | 310 ++++++++++++++++++ 5 files changed, 466 insertions(+), 39 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 66d94daa..619f7ac8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -1,14 +1,24 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.LazyListLayoutInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -16,6 +26,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.layout.layout +import kotlin.math.roundToInt import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb @@ -29,6 +46,7 @@ import androidx.tv.material3.Text import coil3.compose.AsyncImage import coil3.compose.AsyncImagePainter import androidx.compose.runtime.mutableStateOf +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T @@ -36,10 +54,19 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T /** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */ @Composable fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { + val visible = shell.requestsVisible + val fade = rememberSheetFade(visible) + if (!visible && fade.value == 0f) return val state = shell.requests - val data = state.snapshot + val current = RequestsPanelFrame(state.snapshot, state.focusedId, state.expanded, state.windowStart, state.windowSize, + state.recoveryFocused, state.ratingsVisible, shell.requestsExpansionInput) + val retained = remember { mutableStateOf(current) } + SideEffect { if (visible) retained.value = current } + val frame = if (visible) current else retained.value + val data = frame.data val ratingsReader = LocalScreenRatings.current - LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { + LaunchedEffect(visible, state, state.focusedId, data.signedIn, data.status) { + if (!visible) return@LaunchedEffect if (ratingsReader == null) state.awaitFocusedRatings() else if (data.signedIn) state.focused?.let { item -> ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values -> @@ -50,8 +77,9 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } } } - Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim")) - Column(modifier.fillMaxHeight().width(L.sheetWidth) + Box(modifier.fillMaxSize().sheetScrim { fade.value }.focusProperties { canFocus = false } + .clickable { if (visible) shell.closeRequests() }.testTag("coming-in-scrim")) + Column(modifier.sheetPanelFade { fade.value }.fillMaxHeight().width(L.sheetWidth) .offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth) .drawWithCache { val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { @@ -61,9 +89,11 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { onDrawBehind { drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) } } - }.pointerInput(Unit) { + }.focusProperties { canFocus = visible } + .then(if (visible) Modifier else Modifier.clearAndSetSemantics { }) + .pointerInput(visible) { awaitPointerEventScope { - while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() } + while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } } } .padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY) @@ -71,10 +101,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg) when { data.status == ComingInStatus.Loading && data.items.isEmpty() -> { - LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) { + LazyColumn(Modifier.skeletonPulse(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() } } - SheetButton("Close") { shell.closeRequests() } + SheetButton("Close", interactive = visible) { shell.closeRequests() } } data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> { Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)) @@ -86,34 +116,50 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { } Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted) } - SheetButton("Try again", primary = true) { shell.retryRequests() } + SheetButton("Try again", primary = true, interactive = visible) { shell.retryRequests() } + } + data.items.isEmpty() -> { + Column(Modifier.padding(top = T.emptyMargin), verticalArrangement = Arrangement.spacedBy(T.receiptGap)) { + Text("Nothing coming in.", style = ComingInTypography.body, color = C.muted) + Text("New requests show up here.", style = ComingInTypography.body, color = C.muted) + } + SheetButton("Close", interactive = visible) { shell.closeRequests() } } - data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) else -> { if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn) data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) } if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) - val start = state.windowStart - val visibleRows = remember(data, start, state.windowSize) { - data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) + val start = frame.start + val listState = rememberLazyListState() + val placement = rememberRequestPlacement(frame, listState) + val visibleRows = remember(data, start, frame.windowSize) { + data.ordered.subList(start, (start + frame.windowSize).coerceAtMost(data.ordered.size)) } - LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) { + LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), + state = listState, verticalArrangement = Arrangement.spacedBy(T.listGap)) { val keys = visibleRows.map { it.id }.uniqueKeys() - itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { _, item -> - ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId, - state.ratingsVisible && data.signedIn && item.id == state.focusedId, - onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) + itemsIndexed(visibleRows, key = { index, _ -> keys[index] }, contentType = { _, _ -> "request" }) { index, item -> + ComingInRow(item, shell.person, !frame.recoveryFocused && item.id == frame.focusedId, frame.expanded && item.id == frame.focusedId, + frame.ratingsVisible && data.signedIn && item.id == frame.focusedId, + onFocus = { if (visible) state.focus(item.id) }, onClick = { if (visible) { state.focus(item.id); shell.activateRequest() } }, + modifier = Modifier.layout { measurable, constraints -> + val row = measurable.measure(constraints) + layout(row.width, row.height) { + row.placeRelative(0, placement.offset(keys[index], listState.layoutInfo)) + } + }, + readinessProgress = { placement.progress.value }, interactive = visible) } } - if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() } + if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = frame.recoveryFocused, interactive = visible) { shell.retryRequests() } } } } } @Composable -private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { - Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } +private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, interactive: Boolean = true, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = { if (interactive) onClick() }) } } @Composable @@ -128,32 +174,95 @@ private fun ComingInSkeleton() { } } +private data class RequestsPanelFrame(val data: ComingInSnapshot, val focusedId: String?, val expanded: Boolean, + val start: Int, val windowSize: Int, val recoveryFocused: Boolean, val ratingsVisible: Boolean, val expansionInput: Long) + +private class RequestPlacementMemory(var frame: RequestsPanelFrame, var motion: RequestsPlacement? = null) + +/** Placement reads one clock; target-offset caching happens only when the list is measured. */ +private class RequestsPlacement(val animate: Boolean, private val origins: Map) { + val progress: Animatable = Animatable(if (animate) 0f else 1f) + private var measured: LazyListLayoutInfo? = null + private var targets: Map = emptyMap() + + fun offset(key: Any, info: LazyListLayoutInfo): Int { + val progress = progress.value + if (!animate || progress == 1f) return 0 + if (measured !== info) { + targets = info.visibleItemsInfo.associate { it.key to it.offset } + measured = info + } + val target = targets[key] ?: return 0 + return (((origins[key] ?: target) - target) * (1f - progress)).roundToInt() + } +} + +/** Only remote expansion toggles animate; interruptions start at the current visual placement. */ +@Composable +private fun rememberRequestPlacement(frame: RequestsPanelFrame, listState: LazyListState): RequestsPlacement { + val previous = remember { RequestPlacementMemory(frame) } + val reduced = reducedMotion() + val motion = remember(frame, reduced) { + val animate = !reduced && previous.frame.let { + it.expansionInput != frame.expansionInput && it.data === frame.data && it.focusedId == frame.focusedId && + it.start == frame.start && it.expanded != frame.expanded + } + val info = listState.layoutInfo + val origins = if (animate) info.visibleItemsInfo.associate { + it.key to it.offset + (previous.motion?.offset(it.key, info) ?: 0) + } else emptyMap() + RequestsPlacement(animate, origins) + } + SideEffect { previous.frame = frame; previous.motion = motion } + LaunchedEffect(motion) { + if (motion.animate) motion.progress.animateTo(1f, tween(M.lateLineFade, easing = M.easeOut)) + } + return motion +} + /** Thumbnail slots do not resize when the text or the readiness block grows. */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class) @Composable fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean, - showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, + readinessProgress: () -> Float = { 1f }, interactive: Boolean = true) { val foreground = if (focused) C.onFocus else C.fg val secondary = if (focused) C.onFocus else C.muted val context = LocalContext.current val request = remember(item.poster, context) { item.poster?.let { WolfskinImages.request(context, it, (L.thumbWidth.value * 2).toInt(), (L.thumbHeight.value * 2).toInt(), true) } } - val posterReady = remember(request) { mutableStateOf(false) } - Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow) + val posterState = remember(request) { mutableStateOf(AsyncImagePainter.State.Empty) } + var collapsedHeight by remember { mutableIntStateOf(0) } + Row(modifier.fillMaxWidth().testTag("coming-row-${item.id}") + .onSizeChanged { if (!expanded) collapsedHeight = it.height } + .drawWithContent { + val progress = readinessProgress() + if (expanded && collapsedHeight > 0 && progress < 1f) { + val bottom = collapsedHeight + (size.height - collapsedHeight) * progress + clipRect(left = -size.width, top = -size.height, right = size.width * 2, bottom = bottom) { + this@drawWithContent.drawContent() + } + } else drawContent() + }.focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel)) - .pointerInput(item.id) { + .then(if (interactive) Modifier.pointerInput(item.id) { awaitPointerEventScope { while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus() } - }.clickable(onClick = onClick) - .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"), + } else Modifier).clickable(enabled = interactive, onClick = onClick) + .padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) { - AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, - modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) - .background(if (posterReady.value) Color.Transparent else C.surface3), - onState = { posterReady.value = it is AsyncImagePainter.State.Success }, - contentScale = androidx.compose.ui.layout.ContentScale.Crop) + Box(Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb)) + .background(if (posterState.value is AsyncImagePainter.State.Success) Color.Transparent else C.surface3) + .testTag("coming-thumb-${item.id}"), contentAlignment = Alignment.Center) { + AsyncImage(model = request, imageLoader = WolfskinImages.loader(context), contentDescription = null, + modifier = Modifier.fillMaxSize(), onState = { posterState.value = it }, + contentScale = androidx.compose.ui.layout.ContentScale.Crop) + if (request == null || posterState.value is AsyncImagePainter.State.Error) { + Glyph(if (item.mediaType == "movie") "film" else "tv", C.dim, Modifier.size(T.thumbGlyph)) + } + } Column(Modifier.weight(1f)) { Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 3499479c..e875fbc2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -82,6 +82,8 @@ class ShellState( var peopleStatus by mutableStateOf(PeopleStatus.Ready) var recoveryIndex by mutableStateOf(0) var requestsVisible by mutableStateOf(false) + internal var requestsExpansionInput = 0L + private set var toast by mutableStateOf(null) private val personRequests = mutableMapOf() val requests: ComingInState get() = personRequests.getValue(person.key) @@ -161,8 +163,12 @@ class ShellState( when { key == ShellKey.Back -> closeRequests() key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests() - key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() - requests.key(key) -> activateRequest() + key == ShellKey.Ok && requests.snapshot.items.isEmpty() && requests.snapshot.status != ComingInStatus.Unavailable -> closeRequests() + else -> { + val expanded = requests.expanded + if (requests.key(key)) activateRequest() + if ((key == ShellKey.Right || key == ShellKey.Left) && requests.expanded != expanded) requestsExpansionInput++ + } } return true } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index c6dc38b1..47687caa 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -96,12 +96,14 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + FocusRingHost { + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + } NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) - if (state.requestsVisible) ComingInSheet(state) + ComingInSheet(state) overlay() } - state.toast?.let { RecoveryToast(it) } + RecoveryToastHost(state.toast) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt index cba88883..5dcd1baf 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsKeysTest.kt @@ -100,9 +100,9 @@ class RequestsKeysTest { assertEquals(1, retries) assertTrue(state.requestsVisible) rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) } + rule.onNodeWithText("New requests show up here.").assertExists() send(Key.DirectionDown, Key.DirectionCenter) - rule.onNodeWithText("Nothing coming in.").assertExists() - assertTrue(state.requestsVisible) + assertFalse(state.requestsVisible) } @Test fun cachedRatingsRenderInTheTelevisionLocale() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt new file mode 100644 index 00000000..3350674d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RequestsPolishTest.kt @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.intercept.Interceptor +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.awaitCancellation +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M + +/** Native frames exercise Requests transitions without replacing the shell's key or focus policy. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RequestsPolishTest { + @get:Rule val rule = createAndroidComposeRule() + private val reduced = mutableStateOf(false) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + rule.mainClock.autoAdvance = false + } + + private fun shell(snapshot: ComingInSnapshot = requestsFixture()) = ShellState(fixturePeople(), "justin").apply { + pickerVisible = false + pickerLaunch = false + enterDestination("settings") + settingsRow = 2 + requests.update(snapshot) + } + + private fun mount(state: ShellState) { + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { + WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } + } } + rule.mainClock.advanceTimeBy(32) + rule.waitForIdle() + } + + private fun change(block: () -> Unit) { + rule.runOnIdle { block(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + + private fun capture(idea: String, name: String): Bitmap { + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea").apply { mkdirs() } + val file = File(directory, "$name.png") + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath) + } + + private fun csv(idea: String, rows: List) { + File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$idea/samples.csv").writeText(rows.joinToString("\n", postfix = "\n")) + } + + private fun brightness(pixel: Int) = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + private fun monotonic(values: List, increasing: Boolean) { + assertNotEquals("Transition must change the sampled value", values.first(), values.last()) + values.zipWithNext().forEach { (first, second) -> assertTrue("Non-monotonic samples: $values", if (increasing) second >= first - 1f else second <= first + 1f) } + assertTrue("Transition must include an intermediate frame: $values", values.any { it > minOf(values.first(), values.last()) + 1 && it < maxOf(values.first(), values.last()) - 1 }) + } + + @Test fun requestsPanelFadesWithItsScrim() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)) + mount(state) + val samples = mutableListOf("mode,phase,timeMs,scrim,panel") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce; state.openRequests() } + val entering = List(13) { index -> + val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}open-${index.toString().padStart(2, '0')}") + val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat() + image.recycle() + samples += "$reduce,open,${index * 16},${pixels.first},${pixels.second}" + rule.mainClock.advanceTimeBy(16) + pixels + } + val row = state.settingsRow + val nav = state.navFocused + change { state.closeRequests() } + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.onNodeWithTag("coming-in-scrim").performTouchInput { click(Offset(400f, 900f)) } + val leaving = List(9) { index -> + val image = capture("P06", "requests-${if (reduce) "reduced-" else ""}close-${index.toString().padStart(2, '0')}") + val pixels = brightness(image.getPixel(400, 900)).toFloat() to brightness(image.getPixel(1500, 900)).toFloat() + image.recycle() + samples += "$reduce,close,${index * 16},${pixels.first},${pixels.second}" + rule.mainClock.advanceTimeBy(16) + pixels + } + csv("P06", samples) + monotonic(entering.map { it.first }, false) + monotonic(entering.map { it.second }, true) + monotonic(leaving.map { it.first }, true) + monotonic(leaving.map { it.second }, false) + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.onNodeWithTag("coming-in").assertDoesNotExist() + val end = if (reduce) 5 else 10 + assertEquals(entering.last(), entering[end + 1]) + assertEquals(leaving.last(), leaving[if (reduce) 6 else 8]) + } + } + + @Test fun loadingRowsBreatheAsOneGroupAndStopWhenLoaded() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Loading)).apply { openRequests() } + mount(state) + rule.mainClock.advanceTimeBy(240) + val samples = mutableListOf("mode,timeMs,thumb1,thumb2,button") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce } + val close = rule.onNodeWithText("Close").fetchSemanticsNode().boundsInRoot + val times = ((0..1400 step 100).toList() + 350).sorted() + val values = times.mapIndexed { index, time -> + val image = capture("P07", "requests-loading-${if (reduce) "reduced-" else ""}${time.toString().padStart(4, '0')}") + val pixels = listOf(image.getPixel(1240, 240), image.getPixel(1240, 418), image.getPixel(close.center.x.toInt(), close.bottom.toInt() + 8)) + image.recycle() + samples += "$reduce,$time,${pixels.joinToString(",")}" + if (index < times.lastIndex) rule.mainClock.advanceTimeBy((times[index + 1] - time).toLong(), ignoreFrameDuration = true) + pixels + } + csv("P07", samples) + assertEquals(values.map { it[0] }, values.map { it[1] }) + assertEquals(1, values.map { it[2] }.distinct().size) + if (reduce) assertEquals(1, values.map { it[0] }.distinct().size) + else { + assertNotEquals(values[0][0], values[4][0]) + assertNotEquals(values[4][0], values[8][0]) + } + } + change { reduced.value = false; state.requests.update(requestsFixture()) } + rule.mainClock.advanceTimeBy(400) + rule.onNodeWithText("Close").assertDoesNotExist() + assertTrue("Loaded panel must leave the recomposer idle", androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork }) + } + + @Test fun readinessExpansionPushesRowsBelowWithoutRemeasure() { + val state = shell().apply { openRequests(); requests.key(ShellKey.Down) } + mount(state) + // Rating dwell changes layout independently; toggles require settled metadata. + rule.mainClock.advanceTimeBy(M.ratingDwell.toLong()) + val samples = mutableListOf("mode,phase,timeMs,focusedTop,followingTop") + for (reduce in listOf(false, true)) { + change { reduced.value = reduce } + val focusedBounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot + fun focusedTop(): Float { + val bounds = rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot + val scale = WolfskinTokens.Focus.scaleRow + val scaleOffset = kotlin.math.abs(bounds.height - focusedBounds.height) * (scale - 1f) / (2f * scale) + assertEquals(focusedBounds.top, bounds.top, scaleOffset + 0.5f) + return bounds.top + } + val collapsed = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + change { state.key(ShellKey.Right) } + val expanding = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + val fixed = focusedTop() + capture("P09", "expand-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle() + samples += "$reduce,expand,${index * 16},$fixed,$top" + rule.mainClock.advanceTimeBy(16) + top + } + change { state.key(ShellKey.Left) } + val collapsing = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + capture("P09", "collapse-${if (reduce) "reduced-" else ""}${index.toString().padStart(2, '0')}").recycle() + samples += "$reduce,collapse,${index * 16},${focusedTop()},$top" + rule.mainClock.advanceTimeBy(16) + top + } + csv("P09", samples) + assertEquals(collapsed, collapsing.last(), 0.1f) + if (reduce) { + assertTrue(expanding.all { kotlin.math.abs(it - expanding.last()) < 0.1f }) + assertTrue(collapsing.all { kotlin.math.abs(it - collapsed) < 0.1f }) + } else { + monotonic(listOf(collapsed) + expanding, true) + assertTrue("Expansion must not finish early", expanding[5] < expanding.last() - 1f) + monotonic(listOf(expanding.last()) + collapsing, false) + assertTrue("Collapse must not finish early", collapsing[5] > collapsing.last() + 1f) + } + } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P09") + for (phase in listOf("expand", "collapse")) { + val animated = BitmapFactory.decodeFile(File(directory, "$phase-15.png").absolutePath) + val snapped = BitmapFactory.decodeFile(File(directory, "$phase-reduced-15.png").absolutePath) + try { assertTrue("Settled $phase pixels must match reduced-motion snapping", animated.sameAs(snapped)) } + finally { animated.recycle(); snapped.recycle() } + } + val collapsedTop = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + change { reduced.value = false; state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.key(ShellKey.Left) } + val retargeted = List(16) { index -> + val top = rule.onNodeWithTag("coming-row-x2").fetchSemanticsNode().boundsInRoot.top + capture("P09", "retarget-${index.toString().padStart(2, '0')}").recycle() + samples += "false,retarget,${index * 16},${rule.onNodeWithTag("coming-row-x1").fetchSemanticsNode().boundsInRoot.top},$top" + rule.mainClock.advanceTimeBy(16) + top + } + assertTrue("An interrupted expand must return from its current placement", retargeted.first() > collapsedTop + 1f) + monotonic(retargeted, false) + assertEquals(collapsedTop, retargeted.last(), 0.1f) + change { reduced.value = false; state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.key(ShellKey.Down) } + val jumped = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top + rule.mainClock.advanceTimeBy(240) + assertEquals(jumped, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f) + rule.mainClock.advanceTimeBy(M.ratingDwell.toLong()) + change { state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(48) + change { state.requests.update(state.requests.snapshot.copy(partial = true)) } + val refreshed = rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top + rule.mainClock.advanceTimeBy(240) + assertEquals(refreshed, rule.onNodeWithTag("coming-row-x3").fetchSemanticsNode().boundsInRoot.top, 0.1f) + csv("P09", samples) + } + + @Test fun emptyRequestsCloseReturnsToSettingsRow() { + val state = shell(ComingInSnapshot(status = ComingInStatus.Ready)).apply { openRequests() } + mount(state) + rule.mainClock.advanceTimeBy(240) + capture("P10", "requests-empty").recycle() + rule.onNodeWithText("New requests show up here.").assertExists() + val row = state.settingsRow + val nav = state.navFocused + change { state.key(ShellKey.Down); state.key(ShellKey.Ok) } + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + rule.mainClock.advanceTimeBy(160) + change { state.openRequests() } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithText("Close").performClick() + assertFalse(state.requestsVisible) + assertEquals(row, state.settingsRow) + assertEquals(nav, state.navFocused) + } + + @Test fun missingPosterShowsFilmOrTvGlyph() { + val completed = AtomicBoolean(false) + WolfskinImages.configureNetwork(rule.activity) { + add(Interceptor { chain -> + if (chain.request.data == "pending-poster") awaitCancellation() + else chain.proceed().also { if (chain.request.data == "file:///missing-wolfskin-poster.png") completed.set(true) } + }) + } + val source = requestsFixture().items.first() + val items = listOf(source.copy(id = "movie", mediaType = "movie", poster = null), + source.copy(id = "tv", mediaType = "tv", poster = null), + source.copy(id = "failed", mediaType = "movie", poster = "file:///missing-wolfskin-poster.png"), + source.copy(id = "pending", poster = "pending-poster")) + val state = shell(ComingInSnapshot(items, ComingInStatus.Ready)).apply { openRequests(); requests.focus("pending") } + mount(state) + rule.waitUntil(5000) { completed.get() } + rule.mainClock.advanceTimeBy(400) + val image = capture("P12", "missing-thumbs") + try { + fun glyph(id: String): List { + val thumb = rule.onNodeWithTag("coming-thumb-$id", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val left = (thumb.center.x - 20).toInt() + val top = (thumb.center.y - 20).toInt() + return (0 until 40).flatMap { y -> (0 until 40).map { x -> image.getPixel(left + x, top + y) } } + } + val movie = glyph("movie") + val tv = glyph("tv") + val failed = glyph("failed") + val stroke = C.dim.compositeOver(C.surface3).toArgb() + listOf(movie, tv, failed).forEach { pixels -> + assertTrue("The thumbnail centre column must contain the dim glyph stroke", (0 until 40).any { y -> pixels[y * 40 + 20] == stroke }) + } + assertNotEquals(movie, tv) + val deltas = movie.zip(failed).map { (first, second) -> + listOf(16, 8, 0).map { shift -> kotlin.math.abs(((first ushr shift) and 255) - ((second ushr shift) and 255)) } + } + assertTrue("Absent and failed posters must render the same film glyph", deltas.flatten().average() < 2) + assertTrue("Only antialiasing differences are allowed", deltas.count { it.max() <= 8 } >= movie.size * 0.99) + assertTrue("Loading remains a plain thumbnail", glyph("pending").all { it == C.surface3.toArgb() }) + } finally { image.recycle(); WolfskinImages.configureNetwork(rule.activity) {} } + } +} -- 2.49.1 From 6f54bae38eb1bf10282457682366e27d10919df3 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 02:59:51 +0100 Subject: [PATCH 03/17] Glide card focus and carry key-driven scroll momentum --- .../org/justinvisser/wolfskin/design/Focus.kt | 24 +- .../wolfskin/design/FocusRingHost.kt | 132 ++++++++- .../wolfskin/design/FrameScroll.kt | 75 +++++- .../wolfskin/design/HomeScreen.kt | 4 +- .../wolfskin/design/SettingsList.kt | 5 +- .../wolfskin/design/ShelfScroll.kt | 5 +- .../wolfskin/design/FocusGlideTest.kt | 251 ++++++++++++++++++ .../design/RecoveryToastMotionTest.kt | 112 ++++++++ .../wolfskin/design/ScrollMomentumTest.kt | 144 ++++++++++ 9 files changed, 726 insertions(+), 26 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 9a386180..10b8bab2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.remember import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp @@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { /** Lift shadows remain specific to cards and buttons. */ enum class FocusLift { None, Card, Button } -/** Every focus ring straddles the surface edge above content, without a background gap. */ +/** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */ @Composable fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { val reduced = reducedMotion() @@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } + val host = LocalCardRing.current.takeIf { lift == FocusLift.Card } + if (host != null) { + val owner = remember { Any() } + val density = LocalDensity.current + val scale = WolfskinTokens.Focus.scaleCard + val corner = with(density) { radius.toPx() } * scale + val stroke = with(density) { width.toPx() } * scale + DisposableEffect(host, focused) { + onDispose { if (focused) host.release(owner) } + } + SideEffect { if (focused) host.retainFocus() } + return if (!focused) shadow else shadow.onGloballyPositioned { coordinates -> + val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f)) + val size = Size(coordinates.size.width * scale, coordinates.size.height * scale) + host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke)) + } + } return shadow.drawWithCache { val stroke = Stroke(width.toPx()) val corner = CornerRadius(radius.toPx()) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt index 6175d6f9..927f7190 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt @@ -1,10 +1,136 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.runtime.Composable +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.layout.positionInRoot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M -/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */ +private val boundsConverter = TwoWayConverter( + { AnimationVector4D(it.left, it.top, it.right, it.bottom) }, + { Rect(it.v1, it.v2, it.v3, it.v4) }, +) + +internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float) +private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry) +internal val LocalCardRing = staticCompositionLocalOf { null } + +/** Target geometry follows layout; only the displacement from it carries spring velocity. */ +@Stable +internal class CardRingMotion(private val scope: CoroutineScope) { + var reduced = false + private var target by mutableStateOf(null) + private var retained by mutableStateOf(null) + private val displacement = Animatable(Rect.Zero, boundsConverter) + private val radiusDisplacement = Animatable(0f) + val opacity = Animatable(0f) + private var geometryJob: Job? = null + private var fadeJob: Job? = null + private var releaseJob: Job? = null + private var stroke = Stroke(0f) + + fun geometry(): CardRingGeometry? { + val destination = target?.geometry ?: return retained + if (reduced) return destination + return CardRingGeometry(destination.bounds + displacement.value, + (destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke) + } + + fun update(owner: Any, geometry: CardRingGeometry) { + releaseJob?.cancel() + if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke) + val previous = target + if (previous?.owner === owner) { + target = CardRingTarget(owner, geometry) + return + } + val current = this.geometry() + val velocity = displacement.velocity + val radiusVelocity = radiusDisplacement.velocity + geometryJob?.cancel() + target = CardRingTarget(owner, geometry) + geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero) + radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f) + if (!reduced) coroutineScope { + launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) } + launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) } + } + } + if (previous == null) fade(true) + } + + fun drawRing(scope: DrawScope, origin: Offset) { + val geometry = target?.geometry ?: retained ?: return + val alpha = opacity.value + if (alpha <= 0f) return + val delta = if (target != null && !reduced) displacement.value else Rect.Zero + val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f + scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha), + topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin, + size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top), + cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke) + } + + fun retainFocus() { releaseJob?.cancel() } + + fun release(owner: Any) { + // A replacement card registers during layout in the same composition; it must not fade the ring. + releaseJob?.cancel() + releaseJob = scope.launch { + yield() + if (target?.owner === owner) { + retained = geometry() + target = null + geometryJob?.cancel() + fade(false) + } + } + } + + private fun fade(visible: Boolean) { + fadeJob?.cancel() + fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) { + opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut)) + } + } +} + +private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom) +private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom) + +/** One draw-only card ring sits above surface content without changing layout or focus ownership. */ @Composable fun FocusRingHost(content: @Composable () -> Unit) { - content() + val scope = rememberCoroutineScope() + val motion = remember(scope) { CardRingMotion(scope) } + val reduced = reducedMotion() + SideEffect { motion.reduced = reduced } + var origin by remember { mutableStateOf(Offset.Zero) } + Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent { + drawContent() + motion.drawRing(this, origin) + }) { + CompositionLocalProvider(LocalCardRing provides motion, content = content) + } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt index bab07b25..9390b0cd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FrameScroll.kt @@ -1,19 +1,72 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.withFrameNanos +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.WeakHashMap +import kotlin.math.abs +import kotlin.math.sign +import kotlin.math.sqrt -/** Every frame follows focus with the same bounded scroll tween. */ -internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { - if (distance == 0f) return - list.scroll { - var previous = 0f - animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, - easing = WolfskinTokens.Motion.easeOut)) { value, _ -> - scrollBy(value - previous) - previous = value +private class ScrollMomentum { + var velocity = 0f + var frameTime = Long.MIN_VALUE + var generation = 0L + fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE } + + fun release(context: CoroutineContext, generation: Long) { + if (this.generation != generation) return + if (context.isActive) { reset(); return } + // A replacement must claim the carry before the next frame, without keeping the old mutation alive. + CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) { + try { withFrameNanos { } } + finally { if (this@ScrollMomentum.generation == generation) reset() } } } } +// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys. +private val scrollMomentum = WeakHashMap() +private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness) + +/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */ +internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { + val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() } + val generation = ++momentum.generation + if (distance == 0f) { momentum.reset(); return } + try { + list.scroll { + if (NetworkArtTransition.reduced) { + momentum.reset() + scrollBy(distance) + } else { + val maximum = scrollAngularFrequency * abs(distance) + val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f + var previous = 0f + // A fresh frame-clock start would insert a stationary frame on every held-key repeat. + AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance, + animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness), + sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) { + scrollBy(value - previous) + previous = value + momentum.velocity = this.velocity + momentum.frameTime = lastFrameTimeNanos + } + } + } + } finally { + val context = currentCoroutineContext() + momentum.release(context, generation) + context.ensureActive() + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index f6cc248f..eb4c5645 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -69,7 +67,7 @@ fun HomeScreen( (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() }.coerceAtLeast(0f) val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset - view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + animateFrameScroll(view.listState, target - current) view.home.positionedKey = key view.home.positionedReset = view.home.resetScroll } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index fe7adad1..cae18253 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf( @Composable fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState - val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + animateFrameScroll(list, distance) } Box(modifier.fillMaxSize()) { Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt index 614caff7..8e72e00f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShelfScroll.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.tween -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.lazy.LazyListState /** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ @@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int /** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset - if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base, - easing = WolfskinTokens.Motion.ease)) + animateFrameScroll(this, distance) if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt new file mode 100644 index 00000000..24fa42d3 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusGlideTest.kt @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Rect +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import kotlin.math.abs +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FocusGlideTest { + @get:Rule val rule = createAndroidComposeRule() + private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() } + private val accentArgb = WolfskinTokens.Color.accent.toArgb() + private var pixels = IntArray(0) + + @Before fun fullscreen() { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + } + + @Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false) + @Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true) + + private fun shelves(reduced: Boolean) { + val row = mutableIntStateOf(0) + val column = mutableIntStateOf(0) + val memory = HomeViewMemory() + val cards = List(5) { HomeTitle("$it", "Film $it", "movie") } + val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile) + val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + FocusRingHost { Column(Modifier.padding(top = 40.dp)) { + HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {}) + HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {}) + } } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val suffix = if (reduced) "reduced/" else "" + val samples = mutableListOf() + fun frame(phase: String, index: Int): Rect { + val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}") + val bounds = accentBounds(image) + val runs = edgeRuns(image, bounds.centerY()) + samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs" + image.recycle() + return bounds + } + val start = frame("shelf-right", 0) + commit { column.intValue = 1 } + val right = List(21) { index -> step(); frame("shelf-right", index + 1) } + commit { row.intValue = 1 } + val down = List(21) { index -> step(); frame("row-down", index) } + commit { row.intValue = -1 } + val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove + repeat((fadeBudget + 15) / 16 + 1) { step() } + val hidden = capture("${suffix}nav-exit") + assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty) + val lastRing = down.last() + val background = WolfskinTokens.Color.bg.toArgb() + for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) { + val pixel = hidden.getPixel(lastRing.centerX(), y) + assertTrue("No residual ring alpha after the fade deadline", + abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2) + } + hidden.recycle() + File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n")) + assertTrue("Ring must travel to the next card", right.last().left > start.left) + monotonic(listOf(start.left) + right.map { it.left }) + assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2) + monotonic(listOf(start.width()) + down.map { it.width() }) + monotonic(listOf(start.height()) + down.map { it.height() }) + if (reduced) { + assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first()) + assertEquals("Reduced shape change snaps on frame one", down.last(), down.first()) + } else { + assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left) + assertTrue("Width must morph", down.first().width() != down.last().width()) + assertTrue("Height must morph", down.first().height() != down.last().height()) + } + samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) } + } + + @Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false) + @Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true) + + private fun circleRadius(reduced: Boolean) { + val selected = mutableIntStateOf(0) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) { + FocusRingHost { + for (index in 0..1) key(index) { + val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp + Box(Modifier.offset(80.dp, 80.dp).size(120.dp) + .focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard) + .focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius))) + } + } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + fun topSpan(name: String): Int { + val bitmap = capture(name) + val bounds = accentBounds(bitmap) + val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) } + bitmap.recycle() + return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1 + } + val prefix = if (reduced) "reduced/circle" else "circle" + val start = topSpan("$prefix-start") + commit { selected.intValue = 1 } + val spans = List(22) { step(); topSpan("$prefix-$it") } + File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" + + spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n")) + assertTrue("Radius change must change corner pixels", start > spans.last() + 20) + monotonic(listOf(start) + spans) + if (reduced) assertEquals(spans.last(), spans.first()) + else assertTrue("Radius must morph, not jump", spans.first() > spans.last()) + } + + @Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() { + val hosted = mutableStateOf(false) + val kind = mutableIntStateOf(0) + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) { + @Composable fun card() { + val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW + val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH + val modifier = Modifier.offset(60.dp, 40.dp) + if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label) + else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) } + } + if (hosted.value) FocusRingHost { card() } else card() + } + } } + for (shape in 0..2) { + rule.runOnIdle { hosted.value = false; kind.intValue = shape } + rule.mainClock.advanceTimeBy(1000) + val legacy = capture("legacy-$shape") + rule.runOnIdle { hosted.value = true } + rule.mainClock.advanceTimeBy(1000) + val shared = capture("settled-$shape") + assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared)) + var maximumDifference = 0 + for (y in 0 until legacy.height) for (x in 0 until legacy.width) { + val expected = legacy.getPixel(x, y) + val actual = shared.getPixel(x, y) + for (channel in 0..3) { + val shift = channel * 8 + maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255))) + } + } + assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2) + legacy.recycle(); shared.recycle() + } + } + + @Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false) + @Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true) + + private fun library(reduced: Boolean) { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false } + val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + rule.runOnIdle { grid.key(ShellKey.Down) } + repeat(3) { step() } + rule.runOnIdle { grid.key(ShellKey.Right) } + repeat(3) { step() } + rule.runOnIdle { grid.key(ShellKey.Down) } + repeat(30) { step() } + val image = capture(if (reduced) "reduced/library-settled" else "library-settled") + val ring = accentBounds(image) + image.recycle() + assertFalse(ring.isEmpty) + assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f) + } + + private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() } + private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + private fun capture(name: String): Bitmap { + val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() } + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath) + } + private fun accent(pixel: Int): Boolean { + for (channel in 0..2) { + val shift = channel * 8 + if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false + } + return true + } + private fun accentBounds(image: Bitmap): Rect { + val bounds = Rect(image.width, image.height, 0, 0) + val count = image.width * image.height + if (pixels.size < count) pixels = IntArray(count) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + var index = 0 + for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) { + bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y) + bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1) + } + return bounds + } + private fun edgeRuns(image: Bitmap, y: Int): Int { + var runs = 0 + var inside = false + for (x in 0 until image.width) { + val next = accent(image.getPixel(x, y)) + if (next && !inside) runs++ + inside = next + } + return runs + } + private fun monotonic(values: List) { + val direction = if (values.last() >= values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt new file mode 100644 index 00000000..be875722 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryToastMotionTest.kt @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class RecoveryToastMotionTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false) + @Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true) + + private fun exercise(reduced: Boolean) { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val message = mutableStateOf(null) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + RecoveryToastHost(message.value) + } + } } } + rule.mainClock.autoAdvance = false + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() } + val samples = mutableListOf() + var sampleX = 0 + var sampleY = 0 + fun frame(phase: String, index: Int): Int { + val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + val pixel = bitmap.getPixel(sampleX, sampleY) + bitmap.recycle() + val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + samples += "$phase,${index * 16},$brightness" + return brightness + } + fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size + fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() } + show(RecoveryMessage.StillUnreachable) + step() + var inserted = 0 + while (count() == 0 && inserted++ < 3) step() + val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + sampleX = (bounds.left + 40).toInt() + sampleY = (bounds.center.y - 6).toInt() + val entering = List(14) { index -> frame("toast-in", index).also { step() } } + assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first()) + entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) } + val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1 + assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline]) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong()) + assertEquals(entering.last(), frame("hold", 0)) + show(RecoveryMessage.PlaybackUnavailable) + step() + assertEquals(1, count()) + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists() + assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0)) + show(RecoveryMessage.StillUnreachable) + step() + show(null) + step() + assertEquals("Retain the row while fading out", 1, count()) + val leaving = List(14) { index -> frame("toast-out", index).also { step() } } + assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last()) + leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) } + assertEquals(leaving.last(), leaving[deadline]) + rule.onNodeWithTag("recovery-toast").assertDoesNotExist() + show(RecoveryMessage.StillUnreachable) + repeat(14) { step() } + show(null) + step(); step(); step() + val interrupted = frame("interrupted", 0) + assertTrue(interrupted < entering.last()) + show(RecoveryMessage.StillUnreachable) + step() + val resumed = List(14) { index -> frame("resume", index).also { step() } } + // Recomposition may consume one final outgoing frame before retargeting. + assertTrue("Re-entry retains current opacity instead of resetting or snapping", + resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1) + resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) } + assertEquals(entering.last(), resumed.last()) + assertEquals(1, count()) + File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n")) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt new file mode 100644 index 00000000..3bc5b8c7 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScrollMomentumTest.kt @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ScrollMomentumTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false) + @Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true) + @Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false) + @Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true) + + private fun exercise(reduced: Boolean, library: Boolean) { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + val shell = ShellState(fixturePeople(), "justin").apply { + pickerVisible = false + if (library) enterDestination("movies") + navFocused = false + } + val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") }) + shell.view.cards[row.id] = 4 + val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier -> + if (library) LibraryScreen(shell, grid, modifier) + else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {} + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id) + val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2 + val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2 + fun offset(): Float { + val first = track.firstVisibleItemIndex + if (!library) return first * pitch + track.firstVisibleItemScrollOffset + val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 + + if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2) + return top + track.firstVisibleItemScrollOffset + } + fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else + if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns) + fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() } + val samples = mutableListOf() + val offsets = mutableListOf() + val velocities = mutableListOf() + val repeats = mutableListOf() + press() + // Start sampling at the animation's zero-time frame, after composition commits the new focus. + repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + val singleTarget = target() + for (index in 0..80) { + if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + val position = offset() + offsets += position + velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f + samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}" + val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + } + File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n")) + offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) } + assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 }) + assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f) + assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f) + assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f) + if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f) + else repeats.drop(2).forEach { index -> + assertTrue("Repeated key must not stop the track", velocities[index] > 0f) + assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f) + } + } + + @Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false) + @Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true) + + private fun cancelledScroll(reduced: Boolean) { + val visible = mutableStateOf(true) + val target = mutableFloatStateOf(0f) + val list = LazyListState() + fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + if (visible.value) { + LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) } + LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) { + items(30) { Box(Modifier.size(100.dp)) } + } + } else Box(Modifier.requiredSize(500.dp, 100.dp)) + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() } + repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + rule.mainClock.advanceTimeBy(1000) + val resumedTarget = offset() + 600f + rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() } + repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() } + if (reduced) assertEquals(resumedTarget, offset(), 1f) + else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f) + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + assertEquals(resumedTarget, offset(), 1f) + } +} -- 2.49.1 From 9a0ce25a27e84859b7e62ade10bf7f8bf042bc5d Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 03:03:11 +0100 Subject: [PATCH 04/17] Polish Discover sheets and Library row motion --- .../wolfskin/design/DiscoverOverlay.kt | 111 +++++--- .../wolfskin/design/DiscoverScreen.kt | 29 ++- .../wolfskin/design/LibraryGridState.kt | 4 + .../wolfskin/design/LibraryScreen.kt | 10 +- .../justinvisser/wolfskin/design/Motion.kt | 21 +- .../wolfskin/design/ScreenEntry.kt | 18 +- .../wolfskin/design/DiscoverPolishTest.kt | 242 ++++++++++++++++++ .../wolfskin/design/HomePolishRowsTest.kt | 143 +++++++++++ .../wolfskin/design/LibraryPolishTest.kt | 129 ++++++++++ 9 files changed, 658 insertions(+), 49 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index df050bc3..636d5682 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -18,12 +20,23 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.platform.testTag @@ -37,48 +50,67 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L /** Sheets draw after the shell nav: their scrim consumes pointers and focus stays inside. */ @Composable fun DiscoverOverlay(state: DiscoverState) { - val overlay = state.overlay - if (overlay != null && !state.shell.pickerVisible) { - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) { + val current = state.overlay + var retained by remember { mutableStateOf(current) } + SideEffect { if (current != null) retained = current } + val visible = current != null && !state.shell.pickerVisible + val fade = rememberSheetFade(visible) + val overlay = current ?: retained + if (overlay != null && !state.shell.pickerVisible && (visible || fade.value > 0f)) { + Box(Modifier.fillMaxSize().sheetScrim { fade.value }.clickable(enabled = visible, onClick = {}) + .pointerInput(visible) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (!visible) event.changes.forEach { it.consume() } + } + } + }.testTag("discover-overlay")) { Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize() + .sheetPanelFade { fade.value } .dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity), offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero))) .background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { - when (overlay) { - is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state) - is DiscoverOverlayState.Hidden -> { - ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title, showToast = false) } - DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) } + key(overlay) { + when (overlay) { + is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state, visible) + is DiscoverOverlayState.Hidden -> { + ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.") + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Undo", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = false) } + DiscoverButton("Done", overlay.focus == 1, primary = false, enabled = visible) { overlay.focus = 1; state.key(ShellKey.Ok) } + } } - } - is DiscoverOverlayState.Restore -> { - Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) - Text("Hidden from ${state.shell.person.name}’s picks.", - Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), - color = C.muted, style = DiscoverType.body) - Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { - DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title, showToast = true) } - DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null } + is DiscoverOverlayState.Restore -> { + Text(overlay.title.title, Modifier.discoverLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) + Text("Hidden from ${state.shell.person.name}’s picks.", + Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop).discoverLineBox(DiscoverType.body), + color = C.muted, style = DiscoverType.body) + Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + DiscoverButton("Show again", overlay.focus == 0, enabled = visible) { state.restore(overlay.title, showToast = true) } + DiscoverButton("Back", overlay.focus == 1, primary = false, enabled = visible) { state.overlay = null } + } } } } } } } - state.toast?.let { RecoveryToast(it) } + RecoveryToastHost(state.toast) } @Composable -private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { + var previousPhase by remember(sheet) { mutableStateOf(sheet.phase) } + val phase = sheet.phase + SideEffect { previousPhase = phase } Text(if (sheet.held) "Add for someone" else "Request", Modifier.exactLineBox(DiscoverType.sheetTitle), color = C.fg, style = DiscoverType.sheetTitle) Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}", Modifier.exactLineBox(DiscoverType.body, top = D.sheetSubTop, bottom = D.sheetSubGap), color = C.muted, style = DiscoverType.body) if (state.isSignedIn) SheetRatings(sheet.title.ratings) - when (sheet.phase) { - RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) + when (phase) { + RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet, interactive) RequestPhase.LoadingRecipients -> { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) repeat(3) { @@ -89,10 +121,21 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { } } Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton("Close", true, primary = false) { state.overlay = null } + DiscoverButton("Close", true, primary = false, enabled = interactive) { state.overlay = null } } } - else -> RequestOutcome(state, sheet) + else -> { + val opacity = remember(sheet, phase) { Animatable(if (previousPhase == RequestPhase.Sending) 0f else 1f) } + val reduced = reducedMotion() + LaunchedEffect(opacity, reduced) { + if (opacity.value < 1f) opacity.animateTo(1f, + tween(motionDuration(WolfskinTokens.Motion.lateLineFade, reduced), easing = WolfskinTokens.Motion.ease)) + } + Column(Modifier.fillMaxWidth().graphicsLayer { + alpha = opacity.value + compositingStrategy = CompositingStrategy.ModulateAlpha + }, verticalArrangement = Arrangement.spacedBy(D.sheetGap)) { RequestOutcome(state, sheet, interactive) } + } } } @@ -119,7 +162,7 @@ private fun SheetRatings(ratings: List) { } @Composable -private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label) sheet.people.forEachIndexed { index, person -> val focused = sheet.focus == index @@ -127,7 +170,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) .focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } + .clickable(enabled = interactive && !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex } .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle) @@ -141,12 +184,16 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) { if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption) else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption) Box(Modifier.padding(top = D.sheetButtonTop)) { - DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, + glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked, enabled = interactive, + busy = sheet.phase == RequestPhase.Sending, busyLabel = if (sheet.blocked) null else "Sending…") { + sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) + } } } @Composable -private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { +private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState, interactive: Boolean) { val phase = sheet.phase val name = sheet.selectedPerson.name val actor = sheet.result?.actor @@ -169,9 +216,9 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) { if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption) Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) { - DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } - DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null } - } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true) { state.overlay = null } + DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex, enabled = interactive) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) } + DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false, enabled = interactive) { state.overlay = null } + } else DiscoverButton(if (phase == RequestPhase.Receipt) "Done" else "Close", true, enabled = interactive) { state.overlay = null } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 1754b3ce..ca6139c7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -194,11 +196,15 @@ private fun DiscoverChips(state: DiscoverState) { Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = D.chipTop), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { (if (state.service == null) listOf("all" to "All", "movie" to "Movies", "tv" to "Series") else listOf("movie" to "Movies", "tv" to "Series")).forEachIndexed { index, (value, label) -> val focused = state.contentFocused && state.focusedRow == 1 && state.column == index + val selected = state.filter == value + val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) + val foreground = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) + val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Box(Modifier.height(D.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip)) - .border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip)) + .background(fill, RoundedCornerShape(WolfskinTokens.Radius.chip)) + .border(WolfskinTokens.Focus.ringCardRing / 2, border, RoundedCornerShape(WolfskinTokens.Radius.chip)) .clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label) + Text(label, color = foreground, style = DiscoverType.label) } } } @@ -224,12 +230,21 @@ private fun DiscoverStatePanel(state: DiscoverState) { /** Hero and sheet buttons reuse the shell's scale and outside-ring primitives. */ @Composable -fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { +fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, + busy: Boolean = false, busyLabel: String? = null, enabled: Boolean = true, onClick: () -> Unit) { + val foreground = if (primary) C.onFocus else C.fg + val content: @Composable RowScope.() -> Unit = { + if (glyph != null) Glyph(glyph, foreground, Modifier.size(L.buttonIcon)) + Text(label, color = foreground, style = DiscoverType.button, maxLines = 1) + } Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) - .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { - if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1) + .clickable(enabled = enabled, onClick = onClick).padding(horizontal = L.buttonPaddingX), + horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { + if (busyLabel == null) content() + else BusyLabelSwap(busy, busyLabel, color = foreground, style = DiscoverType.button) { + Row(Modifier.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically, content = content) + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index d4f09754..35ed0809 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.derivedStateOf import java.util.Locale @@ -82,6 +83,7 @@ class LibraryGridState( var pagination: LibraryPagination? = null var pageRevision by mutableStateOf(0) var refreshRevision by mutableStateOf(0) + var resultRevision by mutableIntStateOf(0) var pointerReentry: (() -> Int?)? = null var index by mutableStateOf(0) var toolbar by mutableStateOf(false) @@ -147,8 +149,10 @@ class LibraryGridState( ShellKey.Left -> tool = (tool - 1).coerceAtLeast(0) ShellKey.Right -> tool = (tool + 1).coerceAtMost(count - 1) ShellKey.Ok -> { + val previous = items.map { if (catalogue) it.catalogueKey else it.id } if (catalogue) mediaType = listOf("all", "movie", "tv")[tool] else if (tool == 0) sort = (sort + 1) % 3 else unwatched = !unwatched + if (previous != items.map { if (catalogue) it.catalogueKey else it.id }) resultRevision++ index = 0 } else -> Unit diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index bef9e51a..e6dd0982 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -137,7 +137,7 @@ fun LibraryScreen( }.uniqueKeys() items(rowCount, key = { rowKeys[it] }, contentType = { "grid" }) { row -> Box(Modifier.fillMaxWidth().height(rowHeight)) { - Row(Modifier.padding(start = L.safeSide).rowReveal("grid-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { + Row(Modifier.padding(start = L.safeSide).rowReveal("grid-${grid.resultRevision}-$row"), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { for (column in 0 until grid.columns) { val index = row * grid.columns + column val title = items.getOrNull(index) ?: break @@ -194,12 +194,14 @@ private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { labels.forEachIndexed { index, label -> val focused = active && grid.toolbar && grid.tool == index val selected = if (grid.catalogue) listOf("all", "movie", "tv")[index] == grid.mediaType else index == 1 && grid.unwatched + val fill = animatedFocusColor(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest) + val color = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) + val border = animatedFocusColor(if (selected && !focused) C.accent else C.accent.copy(alpha = 0f)) Row(Modifier.height(G.chipHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, CircleShape) - .then(if (selected && !focused) Modifier.border(G.borderWidth, C.accent, CircleShape) else Modifier) + .background(fill, CircleShape) + .border(G.borderWidth, border, CircleShape) .clickable { grid.toolbar = true; grid.tool = index; grid.key(ShellKey.Ok) } .padding(horizontal = G.chipPadding), horizontalArrangement = Arrangement.spacedBy(G.chipContentGap), verticalAlignment = Alignment.CenterVertically) { - val color = if (focused) C.onFocus else if (selected) C.accentText else C.fg if (!grid.catalogue) Glyph(if (index == 0) "advanced" else "eye", color, Modifier.size(G.chipIcon)) if (!grid.catalogue && index == 0) Text(buildAnnotatedString { withStyle(SpanStyle(color = color.copy(alpha = G.chipLabelOpacity), fontWeight = FontWeight.Medium)) { append("Sort") } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt index 111966df..4ad6c0be 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Motion.kt @@ -74,14 +74,25 @@ fun Modifier.skeletonPulse(loading: Boolean = true): Modifier { return graphicsLayer { alpha = clock.alpha.value } } -/** Content enters on its row layer, never one animation per card or a full-screen transition. */ +/** + * Content enters on its row layer, never per card or across the full screen. + * ScreenEntry remembers each enabled identity for that saved visit, so lazy disposal and Back + * do not replay its entrance. Outside an entry, each new composition retains the original reveal. + */ @Composable fun Modifier.rowReveal(identity: Any, enabled: Boolean = true): Modifier { + val memory = LocalRevealMemory.current + val key = identity.toString() + val shouldReveal = remember(memory, identity, enabled) { enabled && memory?.identities?.contains(key) != true } + if (!shouldReveal) return this val reduced = reducedMotion() - val progress = remember(identity, enabled) { Animatable(if (enabled) 0f else 1f) } - LaunchedEffect(progress, reduced) { if (enabled) progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) } - val rise = with(LocalDensity.current) { M.rowRise.toPx() } - return if (!enabled) this else graphicsLayer { + val progress = remember(memory, identity, enabled) { Animatable(0f) } + LaunchedEffect(progress, reduced) { + memory?.identities?.add(key) + progress.animateTo(1f, tween(motionDuration(M.rowEnter, reduced), easing = M.easeOut)) + } + val rise = if (reduced) 0f else with(LocalDensity.current) { M.rowRise.toPx() } + return graphicsLayer { alpha = progress.value translationY = if (reduced) 0f else rise * (1f - progress.value) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt index 503f5338..b3710f1f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -4,6 +4,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable + +/** Entrance identities belong to one navigation entry, not the lifetime of a recycled lazy item. */ +internal class RevealMemory(val identities: MutableSet = mutableSetOf()) { + companion object { + val saver = Saver>( + save = { ArrayList(it.identities) }, + restore = { RevealMemory(it.toMutableSet()) }, + ) + } +} + +internal val LocalRevealMemory = compositionLocalOf { null } internal val LocalScreenView = compositionLocalOf { null } internal val LocalScreenActive = compositionLocalOf { true } @@ -16,7 +30,9 @@ fun isScreenEntryActive(): Boolean = LocalScreenActive.current @Composable fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) } - CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, content = content) + val revealMemory = rememberSaveable(shell.person.key, route, saver = RevealMemory.saver) { RevealMemory() } + CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, + LocalRevealMemory provides revealMemory, content = content) } /** Direct surface previews use the current view when no navigation entry is installed. */ diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt new file mode 100644 index 00000000..f56f928d --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverPolishTest.kt @@ -0,0 +1,242 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** The shipped Discover surface preserves geometry, input and settled pixels through polish motion. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class DiscoverPolishTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + private val fixture = discoverTestFixture() + private val snapshot = fixture.snapshot("justin") + private val scope = CoroutineScope(Dispatchers.Main.immediate) + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") } + private val answer = CompletableDeferred() + private var requestCount = 0 + private val operations = object : DiscoverOperations by RecordingDiscoverOperations(snapshot, "justin") { + override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { requestCount++; return answer.await() } + } + private val state = DiscoverState(shell, snapshot, operations, scope, {}, {}, fixture::service) + + @Before fun fullscreen() = polishFullscreen(rule.activity) + @After fun cancelRequests() { scope.cancel() } + private fun mount() { + val painters = mutableMapOf() + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced, + LocalDiscoverArt provides { path -> painters.getOrPut(path) { BitmapFactory.decodeFile(path).asImageBitmap() } }) { + ScreenEntry(shell, "discover", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + } + private fun request(phase: RequestPhase = RequestPhase.Choose): RequestSheetState { + state.openRequest(true, snapshot.picks.first { it.type == "movie" }, phase) + return (state.overlay as DiscoverOverlayState.Request).state.apply { + selected = people.indexOfFirst { it.key == "mindel" }; focus = buttonIndex + } + } + + @Test fun chipColoursCrossfadeOverFocusMove() { + state.focus(1, 0) + mount() + val all = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val movies = rule.onNode(hasText("Movies") and hasAnyAncestor(hasTestTag("discover-screen")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = movies.center.x.toInt() + val y = (movies.top - 5).toInt() + val frames = PolishFrames(rule, "P01", "discover-chips", reduced) + val resting = frames.capture("settled-before", 0, x, y) + updatePolishState(rule) { state.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(16, x = x, y = y) + frames.finish() + assertEquals("First focus frame keeps old fill", resting.getPixel(x, y), images.first().getPixel(x, y)) + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val midway = images[if (reduced) 2 else 5].getPixel(x, y) + assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway) + assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway) + assertPolishMonotonic(images.map { polishBrightness(it, all.center.x.toInt(), y) }) + val settledIndex = (if (reduced) 80 else 180) / 16 + 1 + assertEquals(C.fg.toArgb(), images[settledIndex].getPixel(x, y)) + assertEquals("The departed selected chip keeps its selected fill", C.accentSoft.toArgb(), images.last().getPixel(all.center.x.toInt(), y)) + resting.recycle(); images.forEach(Bitmap::recycle) + } + + @Test fun requestButtonSaysSendingUntilTheServerAnswers() { + val sheet = request() + mount() + val label = sheet.buttonLabel + val restingBounds = rule.onNode(hasClickAction() and hasText(label)).fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P03", "sending", reduced) + frames.capture("settled-before", 0).recycle() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeByFrame() + val strip = frames.strip(12) + rule.onNodeWithText("Sending…").assertIsDisplayed() + val busyBounds = rule.onNode(hasClickAction() and hasText("Sending…")).fetchSemanticsNode().boundsInRoot + assertEquals(restingBounds.width, busyBounds.width, 0f) + rule.onNodeWithText(label).assertDoesNotExist() + rule.onNodeWithText("Requested").assertDoesNotExist() + updatePolishState(rule) { state.key(ShellKey.Ok) } + assertEquals(RequestPhase.Sending, sheet.phase) + assertEquals("Repeated OK cannot send twice", 1, requestCount) + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeByFrame() + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val reopened = frames.capture("reopened-sending", 0) + assertPolishRegionEquals(strip.last(), reopened, busyBounds, "Reopening a sending sheet shows the busy label at rest") + assertEquals(1, requestCount) + reopened.recycle(); strip.forEach(Bitmap::recycle) + frames.finish() + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithText("Requested").assertIsDisplayed() + rule.onNodeWithText("Sending…").assertDoesNotExist() + } + + @Test fun receiptFadesInOnceAfterSending() { + val sheet = request() + mount() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(240) + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.Receipt, "Justin")) } + rule.mainClock.advanceTimeBy(32) + val heading = rule.onNodeWithText("Requested", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = (heading.left + 5).toInt() + val y = (heading.bottom + 5).toInt() + val frames = PolishFrames(rule, "P04", "receipt", reduced) + assertEquals("Done owns logical focus from frame zero", sheet.buttonIndex, sheet.focus) + rule.onNodeWithText("Done").assertExists() + val images = frames.strip(15, x = x, y = y) + frames.finish() + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val settledIndex = (if (reduced) 80 else 200) / 16 + 1 + assertEquals(images.last().getPixel(x, y), images[settledIndex].getPixel(x, y)) + images.forEach(Bitmap::recycle) + } + + @Test fun reopenedUnconfirmedOutcomeRestsImmediately() { + request() + mount() + updatePolishState(rule) { state.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(240) + updatePolishState(rule) { answer.complete(DiscoverRequestResult(RequestPhase.NotConfirmed, "Justin")) } + rule.mainClock.advanceTimeBy(240) + val heading = rule.onNodeWithText("Not confirmed", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P04", "unconfirmed-reopen", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeByFrame() + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val reopened = frames.capture("reopened", 0) + frames.finish() + assertPolishRegionEquals(settled, reopened, heading, "An outcome that reopens must be fully opaque immediately") + settled.recycle(); reopened.recycle() + } + + @Test fun requestSheetFadesInAndOutWithoutClickThrough() { + mount() + val frames = PolishFrames(rule, "P06", "request-sheet", reduced) + frames.capture("settled-before", 0).recycle() + val details = rule.onNode(hasClickAction() and hasText("Details")).fetchSemanticsNode().boundsInRoot.center + updatePolishState(rule) { request() } + rule.mainClock.advanceTimeBy(32) + val entering = frames.strip(12, "open", 1900, 900) + val heading = rule.onNodeWithText("Request", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + var panelX = heading.left.toInt() + var panelY = heading.top.toInt() + var brightest = -1 + // Interior text avoids the one-level RGB rounding of a nearly opaque dark panel. + for (y in heading.top.toInt() until heading.bottom.toInt()) { + for (x in heading.left.toInt() until heading.right.toInt()) { + val brightness = polishBrightness(entering.last(), x, y) + if (brightness > brightest) { brightest = brightness; panelX = x; panelY = y } + } + } + val button = rule.onNode(hasClickAction() and hasText("Request for Mindel")).fetchSemanticsNode().boundsInRoot.center + val navFocused = shell.navFocused + val row = state.focusedRow + updatePolishState(rule) { state.key(ShellKey.Back) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("discover-overlay").assertExists() + val focusTargets = rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and + SemanticsMatcher.keyIsDefined(SemanticsActions.RequestFocus), useUnmergedTree = true).fetchSemanticsNodes() + updatePolishState(rule) { + focusTargets.forEach { node -> + assertFalse("Exit copies must refuse focus", node.config[SemanticsActions.RequestFocus].action?.invoke() == true) + } + } + rule.onAllNodes(hasAnyAncestor(hasTestTag("discover-overlay")) and isFocused()).assertCountEquals(0) + rule.onRoot().performTouchInput { click(button); click(details) } + assertNull("Retained controls cannot reopen or act on the sheet", state.overlay) + assertNull("Pointers cannot fall through to Details", state.title) + val leaving = frames.strip(8, "close", 1900, 900) + entering.forEachIndexed { index, image -> frames.sample("open-panel", index, image, panelX, panelY) } + leaving.forEachIndexed { index, image -> frames.sample("close-panel", index, image, panelX, panelY) } + frames.finish() + rule.onNodeWithTag("discover-overlay").assertDoesNotExist() + assertEquals(navFocused, shell.navFocused) + assertEquals(row, state.focusedRow) + assertPolishMonotonic(entering.map { polishBrightness(it, panelX, panelY) }) + assertPolishMonotonic(entering.map { polishBrightness(it, 400, 900) }) + assertPolishMonotonic(leaving.map { polishBrightness(it, panelX, panelY) }) + assertPolishMonotonic(leaving.map { polishBrightness(it, 400, 900) }) + val middle = entering[if (reduced) 2 else 5].getPixel(panelX, panelY) + assertNotEquals(entering.first().getPixel(panelX, panelY), middle) + assertNotEquals(entering.last().getPixel(panelX, panelY), middle) + entering.forEach(Bitmap::recycle); leaving.forEach(Bitmap::recycle) + } + + @Test fun scrollBackLaneDoesNotReplay() { + state.focus(state.firstShelf, 0) + mount() + val first = state.lanes.first() + val frames = PolishFrames(rule, "E03", "discover-scroll-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.lanes.lastIndex + state.firstShelf) } + rule.mainClock.advanceTimeBy(240) + rule.onNodeWithTag("discover-lane-${first.id}").assertDoesNotExist() + updatePolishState(rule) { shell.view.listState.requestScrollToItem(state.firstShelf) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(15) + frames.finish() + val restoredBounds = rule.onNodeWithTag("poster-${first.titles[1].key}").fetchSemanticsNode().boundsInRoot + assertPolishRegionEquals(images.last(), images.first(), restoredBounds, "Seen Discover lane must not re-enter") + settled.recycle(); images.forEach(Bitmap::recycle) + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt new file mode 100644 index 00000000..92053eeb --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomePolishRowsTest.kt @@ -0,0 +1,143 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native frames prove that recycled shelves keep their settled pixels, including reduced motion. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomePolishRowsTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + @Before fun fullscreen() = polishFullscreen(rule.activity) + + @Test fun scrollBackShelfDoesNotReplay() { + val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("home"); navFocused = true } + val rows = List(8) { row -> HomeRow("shelf-$row", HomeRowKind.Shelf, title = "Shelf $row", cards = List(8) { HomeTitle("$row-$it", "Film $it", "movie") }) } + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) { + ScreenEntry(shell, "home", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { HomeScreen(shell, HomeSnapshot(rows), it) {} } + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + val bounds = rule.onNodeWithTag("home-card:shelf-0:0").fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "E03", "home-scroll-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(6) } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("home-card:shelf-0:0").assertDoesNotExist() + val freshBounds = rule.onNodeWithTag("home-card:shelf-6:0").fetchSemanticsNode().boundsInRoot + val fresh = frames.capture("first-appearance", 0) + rule.mainClock.advanceTimeBy(240) + val freshSettled = frames.capture("first-appearance-settled", 0) + updatePolishState(rule) { shell.view.listState.requestScrollToItem(0) } + rule.mainClock.advanceTimeBy(32) + val returning = frames.strip(15) + frames.finish() + assertPolishRegionEquals(settled, returning.first(), bounds, "Seen Home shelf must be at rest on its first frame back") + val x = (freshBounds.left + 30).toInt() + val y = (freshBounds.top + 80).toInt() + assertNotEquals("A genuinely new shelf still enters", fresh.getPixel(x, y), freshSettled.getPixel(x, y)) + settled.recycle(); fresh.recycle(); freshSettled.recycle(); returning.forEach(Bitmap::recycle) + } +} + +/** Flush state writes before advancing the manual animation clock. */ +internal fun updatePolishState( + rule: AndroidComposeTestRule, ComponentActivity>, + update: () -> Unit, +) { + rule.runOnIdle { update(); Snapshot.sendApplyNotifications() } +} + +/** Keep captures on the actual full-screen native surface used by the TV. */ +internal fun polishFullscreen(activity: ComponentActivity) { + activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY +} + +/** A manual-clock strip records native pixels and sample coordinates for independent review. */ +internal class PolishFrames( + private val rule: AndroidComposeTestRule, ComponentActivity>, + id: String, + private val name: String, + private val reduced: Boolean, +) { + private val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/$id").apply { mkdirs() } + private val samples = mutableListOf("phase,timeMs,x,y,red,green,blue") + fun capture(phase: String, index: Int, x: Int = 400, y: Int = 900): Bitmap { + rule.waitForIdle() + val file = File(directory, "$name${if (reduced) "-reduced" else ""}-$phase-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + return BitmapFactory.decodeFile(file.absolutePath).also { image -> + for ((sampleX, sampleY) in listOf(x to y, 400 to 900).distinct()) { + sample(phase, index, image, sampleX, sampleY) + } + } + } + fun strip(count: Int, phase: String = "motion", x: Int = 400, y: Int = 900): List = List(count) { index -> + capture(phase, index, x, y).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + } + fun sample(phase: String, index: Int, image: Bitmap, x: Int, y: Int) { + val pixel = image.getPixel(x, y) + samples += "$phase,${index * 16},$x,$y,${android.graphics.Color.red(pixel)},${android.graphics.Color.green(pixel)},${android.graphics.Color.blue(pixel)}" + } + fun finish() { + File(directory, "$name${if (reduced) "-reduced" else ""}-samples.csv").writeText(samples.joinToString("\n") + "\n") + val combined = buildString { + appendLine("strip,phase,timeMs,x,y,red,green,blue") + directory.listFiles { file -> file.name.endsWith("-samples.csv") }.orEmpty().sortedBy { it.name }.forEach { file -> + file.readLines().drop(1).forEach { appendLine("${file.nameWithoutExtension},$it") } + } + } + File(directory, "samples.csv").writeText(combined) + } +} + +internal fun polishBrightness(image: Bitmap, x: Int, y: Int): Int = image.getPixel(x, y).let { + android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) +} + +internal fun assertPolishMonotonic(values: List, changing: Boolean = true) { + if (changing) assertNotEquals("Motion must change the sampled pixel: $values", values.first(), values.last()) + val direction = if (values.last() >= values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("Pixel must move monotonically: $values", (after - before) * direction >= -2) } +} + +internal fun assertPolishRegionEquals(expected: Bitmap, actual: Bitmap, bounds: Rect, message: String) { + val inset = 12 + for (y in bounds.top.toInt() + inset until bounds.bottom.toInt() - inset step 4) { + for (x in bounds.left.toInt() + inset until bounds.right.toInt() - inset step 4) { + assertEquals("$message at ($x,$y)", expected.getPixel(x, y), actual.getPixel(x, y)) + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt new file mode 100644 index 00000000..f361f7ba --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryPolishTest.kt @@ -0,0 +1,129 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** Library transitions reveal real changed results without replaying a retained navigation entry. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LibraryPolishTest(private val reduced: Boolean) { + companion object { + @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "reduced={0}") + fun motionModes() = listOf(arrayOf(false), arrayOf(true)) + } + @get:Rule val rule = createAndroidComposeRule() + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false } + private val titles = List(18) { index -> + val score = when (index % 6) { 0 -> index + 4; 4 -> index - 4; else -> index } / 2.0 + LibraryTitle("film-$index", "Film ${index.toString().padStart(2, '0')}", "movie", addedAt = "${100 + index}", ratings = listOf(LibraryRating("tmdb", score))) + } + private val grid = LibraryGridState("Movies", titles).apply { toolbar = true } + private var entry by mutableStateOf("movies-entry") + @Before fun fullscreen() = polishFullscreen(rule.activity) + private fun mount() { + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalReducedMotion provides reduced) { + val holder = rememberSaveableStateHolder() + entry?.let { key -> holder.SaveableStateProvider(key) { + ScreenEntry(shell, "movies", true) { + ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { LibraryScreen(shell, grid, it) } + } + } } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false + } + + @Test fun toolbarChipColoursCrossfadeOverFocusMove() { + mount() + val chip = rule.onNodeWithText("All", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val x = chip.center.x.toInt() + val y = (chip.top - 5).toInt() + val frames = PolishFrames(rule, "P01", "library-toolbar", reduced) + val resting = frames.capture("settled-before", 0, x, y) + updatePolishState(rule) { grid.key(ShellKey.Right) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(16, x = x, y = y) + frames.finish() + assertEquals(resting.getPixel(x, y), images.first().getPixel(x, y)) + assertPolishMonotonic(images.map { polishBrightness(it, x, y) }) + val midway = images[if (reduced) 2 else 5].getPixel(x, y) + assertNotEquals("Focus colour must interpolate rather than pop", images.first().getPixel(x, y), midway) + assertNotEquals("Focus colour must not finish early", images.last().getPixel(x, y), midway) + assertEquals(C.fg.toArgb(), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y)) + resting.recycle(); images.forEach(Bitmap::recycle) + } + + @Test fun backFromTitleKeepsRowsAtRest() { + mount() + val bounds = rule.onNodeWithTag("poster-film-0").fetchSemanticsNode().boundsInRoot + val frames = PolishFrames(rule, "P05", "library-back", reduced) + val settled = frames.capture("settled", 0) + updatePolishState(rule) { entry = null } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("library-grid").assertDoesNotExist() + updatePolishState(rule) { entry = "movies-entry" } + rule.mainClock.advanceTimeBy(32) + val restored = frames.strip(15) + updatePolishState(rule) { entry = null } + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithTag("library-grid").assertDoesNotExist() + updatePolishState(rule) { entry = "fresh-movies-entry" } + rule.mainClock.advanceTimeBy(32) + val fresh = frames.strip(15, "fresh-entry") + frames.finish() + assertPolishRegionEquals(settled, restored.first(), bounds, "Back must restore the settled row at frame zero") + val x = (bounds.left + 25).toInt(); val y = (bounds.top + 25).toInt() + assertNotEquals("A fresh tab entry still reveals", fresh.last().getPixel(x, y), fresh.first().getPixel(x, y)) + settled.recycle(); restored.forEach(Bitmap::recycle); fresh.forEach(Bitmap::recycle) + } + + @Test fun sortChangeRevealsVisibleRowsTogether() { + grid.sort = 1 + mount() + val first = rule.onNodeWithTag("poster-film-17").fetchSemanticsNode().boundsInRoot + val second = rule.onNodeWithTag("poster-film-11").fetchSemanticsNode().boundsInRoot + val x = (first.left + 100).toInt() + val y0 = (first.top + 65).toInt(); val y1 = (second.top + 65).toInt() + val frames = PolishFrames(rule, "P11", "sort-rating", reduced) + frames.capture("settled-before", 0, x, y0).recycle() + updatePolishState(rule) { grid.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(32) + val images = frames.strip(15, x = x, y = y0) + updatePolishState(rule) { grid.tool = 1 } + rule.mainClock.advanceTimeBy(240) + val beforeUnchanged = frames.capture("unchanged-before", 0, x, y0) + updatePolishState(rule) { grid.key(ShellKey.Ok) } + rule.mainClock.advanceTimeBy(32) + val afterUnchanged = frames.capture("unchanged-after", 0, x, y0) + frames.finish() + assertPolishMonotonic(images.map { polishBrightness(it, x, y0) }) + val background = C.bg.toArgb().let { android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) } + fun ratio(y: Int): Double = (polishBrightness(images[2], x, y) - background).toDouble() / (polishBrightness(images.last(), x, y) - background) + assertEquals("Rows reveal on the same clock", ratio(y0), ratio(y1), .03) + assertTrue("Frame two is still entering", ratio(y0) in 0.01..0.99) + assertPolishRegionEquals(beforeUnchanged, afterUnchanged, first, "A filter with identical membership must not replay") + assertEquals(images.last().getPixel(x, y0), images[(if (reduced) 80 else 180) / 16 + 1].getPixel(x, y0)) + images.forEach(Bitmap::recycle); beforeUnchanged.recycle(); afterUnchanged.recycle() + } +} -- 2.49.1 From b6665d016bbe8025dd67e3083c85a40c23a7aa8e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 02:46:13 +0100 Subject: [PATCH 05/17] Keep live title sessions when returning from trailer apps --- .../wholphin/test/InstrumentedBasicUiTests.kt | 3 +- .../damontecres/wholphin/MainActivity.kt | 20 ++-- .../damontecres/wholphin/MainContent.kt | 29 +---- .../damontecres/wholphin/SignedInContent.kt | 56 +++++++++ .../wholphin/MainActivityResumeTest.kt | 95 +++++++++++++++ .../wholphin/SignedInContentTest.kt | 108 ++++++++++++++++++ .../tv/WolfskinSetupNavigationTest.kt | 2 +- .../wolfskin/tv/WolfskinSetupRollbackTest.kt | 2 +- 8 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 09dc6745..5ccfa603 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -53,9 +53,10 @@ class InstrumentedBasicUiTests { MainContent( backStack = mutableListOf(SetupDestination.ServerList), navigationManager = navigationManager, - appPreferences = mockk(relaxed = true), + userPreferences = mockk(relaxed = true), backdropService = mockk(relaxed = true), screensaverService = screensaverService, + liveSession = MutableStateFlow(null), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 4f2a1364..15524556 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -233,7 +233,7 @@ class MainActivity : AppCompatActivity() { playerBackend = prefs.playbackPreferences.playerBackend } - viewModel.appStart(intent) + viewModel.appStart(intent, resuming = savedInstanceState != null) setContent { MaterialTheme { Surface(Modifier.fillMaxSize()) { @@ -283,6 +283,7 @@ class MainActivity : AppCompatActivity() { userPreferences = userPreferences, backdropService = backdropService, screensaverService = screensaverService, + liveSession = viewModel.serverRepository.current, modifier = Modifier.fillMaxSize(), ) } @@ -318,7 +319,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - viewModel.appStart(null) + viewModel.appStart(null, resuming = true) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page val lastDest = navigationManager.backStack.lastOrNull() @@ -440,7 +441,7 @@ class MainActivityViewModel ) : ViewModel() { private val mutex = Mutex() - fun appStart(intent: Intent?) { + fun appStart(intent: Intent?, resuming: Boolean = false) { viewModelScope.launchDefault { mutex.withLock { try { @@ -464,7 +465,7 @@ class MainActivityViewModel } try { - handleAppStart() + handleAppStart(resuming) } catch (ex: Exception) { Timber.e(ex, "Error during appStart") setupNavigationManager.navigateTo(SetupDestination.ServerList) @@ -567,11 +568,16 @@ class MainActivityViewModel * Handle starting the app including auto sign in and navigating to the right starting page */ @VisibleForTesting - internal suspend fun handleAppStart() { + internal suspend fun handleAppStart(resuming: Boolean = false) { val signInAutomatically = preferences.data.first().signInAutomatically val currentUser = serverRepository.current.value - if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { - // Hot reload + // A picker may be an explicit switch or expired login, even while a user remains cached. + val shown = if (resuming && !signInAutomatically) { + (setupNavigationManager.backStack.lastOrNull() as? SetupDestination.AppContent)?.current + } else null + val canContinue = currentUser != null && shown != null && + currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id + if ((signInAutomatically || canContinue) && currentUser != null && !currentUser.user.isProtected) { Timber.v("Hot reload restoring state") setupNavigationManager.navigateTo( SetupDestination.AppContent(currentUser), diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index d2b7c41e..2b653675 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -11,17 +11,13 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator @@ -29,6 +25,7 @@ import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ScreensaverService @@ -40,7 +37,9 @@ import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import kotlinx.coroutines.flow.StateFlow +/** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */ @Composable fun MainContent( backStack: MutableList, @@ -48,6 +47,7 @@ fun MainContent( userPreferences: UserPreferences, backdropService: BackdropService, screensaverService: ScreensaverService, + liveSession: StateFlow, modifier: Modifier = Modifier, setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, ) { @@ -104,16 +104,7 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!userPreferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { + SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) { WolfskinApp( user = current.user, server = current.server, @@ -121,16 +112,6 @@ fun MainContent( preferences = preferences, modifier = Modifier.fillMaxSize(), ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt new file mode 100644 index 00000000..2d22ded4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.tv.material3.MaterialTheme +import androidx.lifecycle.compose.LifecycleEventEffect +import com.github.damontecres.wholphin.data.CurrentUser +import kotlinx.coroutines.flow.StateFlow + +/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */ +@Composable +internal fun SignedInContent( + current: CurrentUser, + liveSession: StateFlow, + signInAutomatically: Boolean, + content: @Composable () -> Unit, +) { + var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) } + var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) } + fun hideInvalidSession() { + if (!signInAutomatically) { + val live = liveSession.value + val canContinue = live != null && live.server.id == current.server.id && + live.user.id == current.user.id && !live.user.isProtected + if (!canContinue) showContent = false + } + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + hasStopped = true + hideInvalidSession() + } + LifecycleEventEffect(Lifecycle.Event.ON_START) { + // A newly unlocked entry may paint; a returning entry must recheck protection changed while away. + if (hasStopped) { + hideInvalidSession() + hasStopped = false + } + } + if (showContent) { + content() + } else { + Box(Modifier.size(200.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.border, modifier = Modifier.align(Alignment.Center)) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt new file mode 100644 index 00000000..c7b0fce0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -0,0 +1,95 @@ +package com.github.damontecres.wholphin + +import androidx.datastore.core.DataStore +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.* +import java.util.UUID +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainActivityResumeTest { + private val dispatcher = StandardTestDispatcher() + private val repository = mockk() + private val setup = mockk(relaxed = true) + private val navigation = NavigationManager() + private val preferences = mockk>() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val current = CurrentUser(server, + JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")) + private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) + private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)) + + @Before fun configure() { + WholphinDispatchers.configure(dispatcher) + every { preferences.data } returns automatic + every { repository.current } returns MutableStateFlow(current) + every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(current)) + coEvery { repository.getMostRecentServer() } returns RestoredSession.ServerOnly(current.server) + coEvery { repository.restoreSavedSession() } returns RestoredSession.ServerOnly(current.server) + } + + @After fun reset() { WholphinDispatchers.reset() } + + @Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) { + val title = Destination.Wolfskin("title/${UUID.randomUUID()}") + navigation.backStack.add(title) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.AppContent(current)) } + assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList()) + coVerify(exactly = 0) { repository.restoreSavedSession() } + coVerify(exactly = 0) { repository.getMostRecentServer() } + } + + @Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) { + for (enabled in listOf(false, true)) { + for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) { + automatic.value = automatic.value.update { signInAutomatically = enabled } + val protected = current.copy(user = user) + every { repository.current } returns MutableStateFlow(protected) + every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected)) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) } + } + } + } + + @Test fun recreatedActivityWithoutALiveIdentityStillUsesTheColdStartPicker() = runTest(dispatcher) { + every { repository.current } returns MutableStateFlow(null) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + coVerify(exactly = 0) { repository.restoreSavedSession() } + } + + @Test fun warmReturnDoesNotDismissAnExplicitPickerOrAnotherPersonsEntry() = runTest(dispatcher) { + val other = current.copy(user = current.user.copy(id = UUID.randomUUID())) + for (shown in listOf(SetupDestination.UserList(current.server), SetupDestination.AppContent(other))) { + every { setup.backStack } returns mutableListOf(shown) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt new file mode 100644 index 00000000..c2138f15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -0,0 +1,108 @@ +package com.github.damontecres.wholphin + +import android.app.Application +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import java.util.UUID +import kotlinx.coroutines.flow.MutableStateFlow +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.TitleFixture +import org.justinvisser.wolfskin.tv.TitleTrailer +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The signed-in title survives a stopped activity without removing protected-person gates. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SignedInContentTest { + @get:Rule val rule = createAndroidComposeRule() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token") + private val liveSession = MutableStateFlow(CurrentUser(server, user)) + + private fun mount(person: JellyfinUser = user) { + liveSession.value = CurrentUser(server, person) + val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij") + val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } + val title = TitleState(data, shell.person) { action -> + if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url)) + } + rule.setContent { + WolfskinTheme { + SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) { + TitleSurface(title, shell) + } + } + } + rule.onNodeWithTag("title-heading").assertTextEquals("Locke") + } + + private fun stopAndReturn() { + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + } + + @Test fun closingTrailerKeepsTheSameTitleMountedWithAutomaticSignInOff() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")) + val handler = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true } + } + shadowOf(rule.activity.packageManager).addResolveInfoForIntent(intent, handler) + mount() + rule.onNodeWithTag("title-option-trailer").performClick() + assertEquals("test.youtube", shadowOf(rule.activity).nextStartedActivity.`package`) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke") + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "returned-title.png")) + } + + @Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() { + mount(user.copy(pin = "1234")) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun stoppedRequireLoginTitleRemainsHiddenUntilAuthentication() { + mount(user.copy(requireLogin = true)) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() { + mount() + liveSession.value = CurrentUser(server, user.copy(pin = "1234")) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() { + mount() + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + liveSession.value = CurrentUser(server, user.copy(pin = "1234")) + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index 046168fa..6a2f29b7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -99,7 +99,7 @@ class WolfskinSetupNavigationTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, Modifier, + mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier, setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt index 879404b4..b01e145d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver) + mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null)) } fun awaitHeading(resource: Int) { rule.waitUntil(5_000) { -- 2.49.1 From d418a89a2535778ee6353a2125073131d164a878 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 03:15:03 +0100 Subject: [PATCH 06/17] Keep season episode reels in chronological order --- .../wolfskin/tv/TitleSeasonReelTest.kt | 169 ++++++++++++++++++ .../wolfskin/tv/read/TitleReadFacade.kt | 5 +- docker/README.md | 8 + 3 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt new file mode 100644 index 00000000..93253dd1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleSeasonReelTest.kt @@ -0,0 +1,169 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.ItemPlaybackRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.ui.successQueryResult +import com.github.damontecres.wholphin.ui.successResponse +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.ItemsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetEpisodesRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleAction +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitleState +import org.justinvisser.wolfskin.design.TitleSurface +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.util.UUID + +/** The actual SDK projection and native reel must agree on chronology and the activated episode. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34, 36], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleSeasonReelTest { + @get:Rule val rule = createAndroidComposeRule() + @get:Rule val directory = TemporaryFolder() + private val seriesId = UUID(0, 1) + private val series = BaseItemDto(id = seriesId, name = "Desperate Housewives", type = BaseItemKind.SERIES, + providerIds = mapOf("Tmdb" to "693"), people = emptyList()) + private val episodes = (1..24).map { number -> + BaseItemDto(id = UUID(0, 100L + number), name = "Episode $number", type = BaseItemKind.EPISODE, + seriesId = seriesId, parentIndexNumber = 5, indexNumber = number, + providerIds = mapOf("Tmdb" to number.toString()), people = emptyList(), + userData = playbackUserData(UUID(0, 100L + number), 0, played = number < 24)) + } + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + private lateinit var facade: TitleReadFacade + private lateinit var state: TitleState + private val actions = mutableListOf() + + @Before fun setup() { + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns mockk { + every { resources } returns rule.activity.resources + } + val library = mockk() + val items = mockk() + val shows = mockk() + val api = mockk() + every { api.userLibraryApi } returns library + every { api.itemsApi } returns items + every { api.tvShowsApi } returns shows + coEvery { library.getItem(seriesId) } returns successResponse(series) + episodes.forEach { episode -> coEvery { library.getItem(episode.id) } returns successResponse(episode) } + coEvery { items.getItems(any()) } returns successQueryResult((1..8).map { number -> + BaseItemDto(id = UUID(0, 10L + number), type = BaseItemKind.SEASON, indexNumber = number) + }) + coEvery { shows.getEpisodes(any()) } returns successQueryResult(episodes) + coEvery { shows.getNextUp(any()) } returns successQueryResult(listOf(episodes.last())) + val playback = mockk() + coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null + val repository = mockk { + every { currentServer } returns null + every { currentUser } returns null + } + val context = mockk { every { filesDir } returns directory.root } + facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), context) + } + + @After fun teardown() { unmockkObject(WholphinApplication.Companion) } + + private fun render(page: TitlePageData) { + val viewer = ShellPerson("justin", "Justin", "J", false) + val shell = ShellState(listOf(viewer), viewer.key).apply { + pickerVisible = false; pickerLaunch = false; enterDestination("title/series") + } + state = TitleState(page, viewer, actions::add) + rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } + press(Key.DirectionDown) + press(Key.DirectionDown) + repeat(4) { press(Key.DirectionLeft) } + assertEquals("episodes", state.row) + assertEquals(0, state.column) + rule.onNodeWithTag("title-page").assertIsFocused() + } + + @Test fun nextUpAtSeasonEndCannotMoveTheFinalEpisodeToTheStartOfTheReel() = runBlocking { + val page = facade.read(seriesId, 5, preferences) + render(page) + press(Key.DirectionCenter) + assertEquals("The first tile must open E1, not next-up E24", episodes.first().id.toString(), openedEpisode()) + assertEquals("Play remains next-up even though the reel is chronological", episodes.last().id.toString(), page.playTarget?.id) + assertEquals((1..24).toList(), page.episodes.map { it.episode }) + val saved = requireNotNull(facade.readSaved(seriesId, preferences)) + assertEquals(page.episodes.map { it.id }, saved.episodes.map { it.id }) + repeat(7) { press(Key.DirectionRight) } + repeat(2) { press(Key.DirectionLeft) } + press(Key.DirectionUp) + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertEquals("Row return and Left/Right preserve E6", episodes[5].id.toString(), openedEpisode()) + rule.onNodeWithTag("title-episode-slot-5").assertIsDisplayed() + } + + @Test fun openingAnotherEpisodeAndRefreshingCannotReorderTheRememberedReel() = runBlocking { + val opened = facade.read(episodes[11].id, 5, preferences) + render(opened) + repeat(12) { press(Key.DirectionRight) } + assertEquals(episodes[12].id.toString(), state.data.episodes[state.column].id) + val refreshed = facade.read(seriesId, 5, preferences) + rule.runOnIdle { state.update(refreshed) } + press(Key.DirectionCenter) + assertEquals("Refresh must not change E13 into another episode", episodes[12].id.toString(), openedEpisode()) + assertEquals(episodes[11].id.toString(), opened.playTarget?.id) + assertEquals(episodes[11].id.toString(), opened.watchTarget.id) + assertEquals(opened.episodes.map { it.id }, refreshed.episodes.map { it.id }) + rule.onNodeWithTag("title-episode-slot-12").assertIsDisplayed() + } + + private fun openedEpisode() = (actions.last() as TitleAction.OpenTitle).item.id + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(400) + rule.waitForIdle() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 1530b8df..974e22c8 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -192,6 +192,9 @@ class TitleReadFacade @Inject constructor( streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) } + // Rich playback/watch rows are prefixed in storage, not in the season's display order. + val seasonEpisodes = rows.filterTo(ArrayList()) { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason } + seasonEpisodes.sortBy { it.indexNumber } val pageItem = mapItem(item).copy(seasons = seasonNumbers.size) return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) }, rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, @@ -200,7 +203,7 @@ class TitleReadFacade @Inject constructor( cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) }, - episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) }, + episodes = seasonEpisodes.map { mapItem(it, pageItem.backdrop) }, connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) } diff --git a/docker/README.md b/docker/README.md index 5522b541..e3e32c27 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1007,6 +1007,14 @@ Episode shelf skeletons use the same width, height and pitch as ready cards. Person filmography and related-title shelves remain posters; Home continuation tiles are a separate component and retain their series-name context. +Season episode reels use episode-number order independently of the Play/Resume +or explicitly opened episode. The shared live/saved projection keeps rich +playback metadata without promoting that target into the first tile. A refresh +therefore cannot replace the episode under a remembered reel index merely +because the playback target changed. `TitleSeasonReelTest` replays native remote +input on SDK 34 and 36 with 24 season-five episodes and next-up at E24, including +row return, exact activation, cached reads and an explicit-episode refresh. + `TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, person credits and playback mutations to one boundary. `ConductarrTitlePages` -- 2.49.1 From 612ae79f0cb2dddbdea69652155a33a2a31a65e6 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 03:16:09 +0100 Subject: [PATCH 07/17] Polish picker switching and Home playback handoff --- .../damontecres/wholphin/MainContent.kt | 6 + .../wolfskin/tv/ConductarrHomeSurfaceTest.kt | 2 +- .../wolfskin/tv/HomeHeroStartingTest.kt | 166 +++++++++++++ .../wolfskin/tv/HomeRecoveryTest.kt | 8 +- .../wolfskin/tv/HomeSeeAllNavigationTest.kt | 2 +- .../wolfskin/tv/NavigationCutTest.kt | 233 ++++++++++++++++++ .../wolfskin/tv/PersonCommitTest.kt | 185 ++++++++++++++ .../wolfskin/tv/PersonSwitchThreadingTest.kt | 2 +- .../wolfskin/tv/PlayerHandoffTest.kt | 21 +- .../justinvisser/wolfskin/tv/WatchReadTest.kt | 2 +- .../wolfskin/tv/WatchReturnTest.kt | 2 +- .../wolfskin/tv/HomeDestination.kt | 64 +++-- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 108 ++++++-- .../wolfskin/tv/update/UpdateNavigation.kt | 13 - data/tv-spec/spec.md | 41 +++ docker/README.md | 17 ++ .../wolfskin/design/HomeHeader.kt | 8 +- .../wolfskin/design/PersonCommit.kt | 70 ++++++ .../wolfskin/design/WhosWatching.kt | 44 ++-- .../wolfskin/design/PickerPolishTest.kt | 63 +++++ 20 files changed, 974 insertions(+), 83 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt delete mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index d2b7c41e..e4b86e5b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -66,6 +69,9 @@ fun MainContent( NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, + transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index 7842289d..b7e18bf8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest { coEvery { home.readLibrary(user.id) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) try { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } rule.setContent { WolfskinTheme { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt new file mode 100644 index 00000000..c545c680 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -0,0 +1,166 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.io.IOException +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.model.api.BaseItemDto +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeHeroStartingTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var model: WolfskinHomeViewModel + private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) + private val session = WolfskinSession() + private val navigation = NavigationManager() + private val facade = mockk() + private val release = CompletableDeferred() + private val visible = mutableStateOf(true) + private var previousFixtures = false + private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) + private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false } + + @After fun dispose() { + release.complete(null) + if (::model.isInitialized) model.viewModelScope.cancel() + conductarr.close() + ConductarrPreference.updateFixtures(previousFixtures) + } + + private fun mount(reduced: Boolean = false) { + ConductarrPreference.initialize(rule.activity) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(true) + coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() } + every { facade.audiences } returns null + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { + if (visible.value && navigation.backStack.last() !is Destination.Playback) + ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) } + PlaybackHandoffVeil(session.playback) + } + } } } + await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true } + rule.mainClock.advanceTimeBy(1200) + rule.mainClock.autoAdvance = false + } + + @Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false) + @Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true) + + private fun successfulStart(reduced: Boolean) { + mount(reduced) + val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width + press() + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) + press() + assertTrue(navigation.backStack.single() is Destination.Home) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + val samples = mutableListOf() + fun frame(phase: String, index: Int): Int { + val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } + samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size) + return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + } + repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() } + val id = UUID.randomUUID() + release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE)) + await { session.playback.launch != null } + val duration = motionDuration(WolfskinTokens.Motion.base, reduced) + var elapsed = 0 + val fading = mutableListOf() + while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) { + fading.add(frame("home-black", elapsed / 16)) + rule.mainClock.advanceTimeByFrame() + elapsed += 16 + } + assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration) + assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last()) + assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before }) + assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10) + assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId) + assertEquals(2, navigation.backStack.size) + assertNull(session.playback.starting) + assertNull(session.playback.launch) + coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) } + File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n")) + rule.runOnIdle { navigation.goBack() } + rule.mainClock.advanceTimeBy(100) + rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() + rule.onNodeWithText("Starting…").assertDoesNotExist() + } + + @Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() { + mount() + press() + rule.mainClock.advanceTimeBy(200) + rule.onNodeWithText("Starting…").assertIsDisplayed() + release.completeExceptionally(IOException("offline")) + await { model.toast != null } + rule.mainClock.advanceTimeBy(200) + rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + assertTrue(navigation.backStack.single() is Destination.Home) + } + + @Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() { + mount() + press() + rule.mainClock.advanceTimeBy(32) + rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(32) + release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE)) + await { session.playback.starting == null } + rule.mainClock.advanceTimeBy(400) + assertTrue(navigation.backStack.single() is Destination.Home) + assertNull(session.playback.launch) + } + + private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + private fun await(condition: () -> Boolean) { + rule.waitUntil(5000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + shadowOf(Looper.getMainLooper()).idle() + if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() + condition() + } + rule.waitForIdle() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 128dae1e..547fa4b9 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -45,7 +45,7 @@ class HomeRecoveryTest { val facade = mockk() coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -67,7 +67,7 @@ class HomeRecoveryTest { coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) @@ -88,7 +88,7 @@ class HomeRecoveryTest { val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) model.loadLibrary(server, personA, viewerA) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -101,7 +101,7 @@ class HomeRecoveryTest { coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index 595a9b32..55b92066 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val facade = mockk() rule.setContent { val destination = navigation.backStack.last() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt new file mode 100644 index 00000000..6f68f7ea --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -0,0 +1,233 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.* +import com.github.damontecres.wholphin.services.* +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.util.UUID +import kotlinx.coroutines.cancel +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Each navigation change retires the outgoing shell in the first rendered frame. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavigationCutTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var host: PolishAppHost + @After fun dispose() { if (::host.isInitialized) host.close() } + + @Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false) + @Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true) + + private fun cuts(reduced: Boolean) { + host = PolishAppHost(rule) + host.mount(picker = false, reduced = reduced) + rule.mainClock.autoAdvance = false + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + val samples = mutableListOf() + val homeCard = SemanticsMatcher("Home card") { + it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) && + it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:") + } + fun strip(phase: String) { + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + repeat(21) { index -> + rule.mainClock.advanceTimeByFrame() + val clock = rule.mainClock.currentTime + rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath) + assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) + val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size + val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size + val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size + assertEquals("Only the live destination may own a nav bar", 1, shells) + assertEquals(if (phase == "open-title") 1 else 0, titles) + if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards) + else assertTrue("Back immediately paints Home's shelves", cards > 0) + samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards) + } + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) } + strip("open-title") + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + strip("back") + rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } + strip("tab") + File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n")) + } + + @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { + host = PolishAppHost(rule) + host.mount(picker = false) + rule.mainClock.autoAdvance = false + var launches = 0 + rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } } + rule.mainClock.advanceTimeBy(32) + val revision = host.session.state!!.inputRevision + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) } + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertEquals(revision, host.session.state!!.inputRevision) + assertEquals(0, launches) + rule.mainClock.advanceTimeBy(200) + assertEquals(1, launches) + assertNull(host.session.playback.starting) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertTrue(host.session.state!!.navFocused) + assertFalse(rule.activity.isFinishing) + } + + @Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() { + host = PolishAppHost(rule) + host.mount(picker = false) + rule.runOnIdle { host.session.playback.begin("hero-play") } + rule.mainClock.advanceTimeBy(32) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertNull(host.session.playback.starting) + assertTrue(host.session.state!!.navFocused) + assertFalse(rule.activity.isFinishing) + } + + @Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false) + @Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true) + + private fun setupCuts(reduced: Boolean) { + val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) + val stack = mutableStateListOf(SetupDestination.ServerList) + val screensaver = mockk() + every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { + com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), + mockk(relaxed = true), screensaver, + Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), + setupInterceptor = { destination, modifier -> + androidx.tv.material3.Text(destination.javaClass.simpleName, modifier) + true + }) + } } } + rule.mainClock.autoAdvance = false + for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { + val previous = stack.single().javaClass.simpleName + rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + val clock = rule.mainClock.currentTime + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath) + assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) + rule.onNodeWithText(previous).assertDoesNotExist() + rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1) + } + } +} + +/** Real shell integration with fixture data and an injectable saved-user switch. */ +internal class PolishAppHost(private val rule: AndroidComposeTestRule, ComponentActivity>) : AutoCloseable { + val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name -> + JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null) + } + val session = WolfskinSession() + val navigation = NavigationManager() + val repository = mockk(relaxed = true) + val setup = mockk(relaxed = true) + private val conductarr = ConductarrSession(MemoryCacheStore()) + private val models = mutableListOf() + private var oldFixtures = false + private var oldScreens = true + private var current by mutableStateOf(users.first()) + val model get() = models.last() + var reduceMotion by mutableStateOf(false) + + fun showUser(user: JellyfinUser) { current = user } + + fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) { + reduceMotion = reduced + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + oldFixtures = ConductarrPreference.fixtures + oldScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(fixtures) + WolfskinScreenPreference.enabled = true + coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) } + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + every { setup.navigateTo(any()) } answers { + val destination = firstArg() + if (destination is SetupDestination.AppContent) current = destination.current.user + } + rule.setContent { + CompositionLocalProvider(LocalReducedMotion provides reduceMotion) { + key(current.id) { + val model = remember { + WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), + mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), + org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } + } + DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } + WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model, + homeContent = { shell, _, user, modifier -> + val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) } + HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details || action is HomeAction.Play) + navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke"))) + } + }, + titleContent = { route, shell, _ -> + val title = remember(route) { + val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) }) + TitleState(fixture.title("Locke", shell.person.key), shell.person) { + if (it == TitleAction.Pop) navigation.goBack() + } + } + androidx.activity.compose.BackHandler { title.back(shell) } + TitleSurface(title, shell) + }) + } + } + } + await { session.state?.people?.size == users.size && model.signedInPerson == "justin" } + rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker } + rule.mainClock.advanceTimeBy(1200) + } + + fun await(condition: () -> Boolean) { + rule.waitUntil(10_000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + shadowOf(Looper.getMainLooper()).idle() + if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() + condition() + } + rule.waitForIdle() + } + override fun close() { + models.forEach { it.viewModelScope.cancel() } + conductarr.close() + ConductarrPreference.updateFixtures(oldFixtures) + WolfskinScreenPreference.enabled = oldScreens + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt new file mode 100644 index 00000000..66f184cd --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt @@ -0,0 +1,185 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PersonCommitTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var host: PolishAppHost + private val release = CompletableDeferred() + @After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() } + + @Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false) + @Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true) + + private fun heldSwitch(reduced: Boolean) { + host = PolishAppHost(rule) + host.mount(reduced = reduced) + coEvery { host.repository.changeUser(host.server, any()) } coAnswers { + release.await() + CurrentUser(host.server, secondArg()) + } + var pendingUser: JellyfinUser? = null + every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user } + rule.mainClock.autoAdvance = false + press(listOf(Key.DirectionRight)) + rule.mainClock.advanceTimeBy(400) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath) + val commit = choose(listOf(Key.DirectionCenter)) + val origin = commit.startFrameNanos + val clockOrigin = rule.mainClock.currentTime + val samples = mutableListOf() + val others = mutableListOf() + val chosen = mutableListOf() + val bg = WolfskinTokens.Color.bg.toArgb() + repeat(31) { index -> + val timeMs = rule.mainClock.currentTime - clockOrigin + assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs) + rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist() + val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin) + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + val other = image.getPixel(600, 500) + val selected = image.getPixel(900, 500) + others.add(android.graphics.Color.red(other)) + chosen.add(selected) + if (timeMs >= if (reduced) 80 else 320) { + val pixels = IntArray(image.width * image.height) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg }) + } + samples.add("$timeMs,$other,$selected") + } finally { image.recycle() } + rule.mainClock.advanceTimeByFrame() + } + assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before }) + assertNotEquals(others.first(), others.last()) + if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() }) + File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n")) + val shell = host.session.state!! + val revision = shell.inputRevision + val stack = host.navigation.backStack.toList() + press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape)) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) } + assertEquals(revision, shell.inputRevision) + assertEquals("mindel", shell.person.key) + assertEquals(stack, host.navigation.backStack.toList()) + if (reduced) { + rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + val file = File(directory, "motion-toggle.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500)) + assertEquals(bg, image.getPixel(100, 80)) + } finally { image.recycle() } + } + release.complete(Unit) + host.await { pendingUser != null } + rule.mainClock.advanceTimeBy(160) + assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit) + rule.runOnIdle { host.showUser(pendingUser!!) } + host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null } + assertEquals(origin, commit.startFrameNanos) + rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed() + rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32) + rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath) + coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) } + } + + @Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() { + host = PolishAppHost(rule) + host.mount() + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionCenter)) + assertNotNull(host.session.personCommit) + finishTimeline() + assertNull(host.session.personCommit) + rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed() + coVerify(exactly = 0) { host.repository.changeUser(any(), any()) } + } + + @Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected") + @Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true) + + private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) { + host = PolishAppHost(rule) + host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user } + host.mount(fixtures = false) + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionRight, Key.DirectionCenter)) + assertNotNull(host.session.personCommit) + awaitSwitchWork() + verify(exactly = 0) { host.setup.navigateTo(any()) } + finishTimeline() + assertNull(host.session.personCommit) + coVerify(exactly = 0) { host.repository.changeUser(any(), any()) } + verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } + } + + @Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() { + host = PolishAppHost(rule) + host.mount(fixtures = false) + coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed") + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionRight, Key.DirectionCenter)) + awaitSwitchWork() + finishTimeline() + assertNull(host.session.personCommit) + verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } + } + + private fun choose(keys: List): PersonCommit { + press(keys) + val commit = requireNotNull(host.session.personCommit) + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null } + return commit + } + + private fun finishTimeline() { + repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + } + + private fun awaitSwitchWork() { + rule.waitUntil(5000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + host.session.personCommit?.switchEnded == true + } + } + + private fun press(keys: List) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 2ae2763a..44dfa233 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -219,7 +219,7 @@ class PersonSwitchThreadingTest { coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) + val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession()) val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true)) try { conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index dca356a7..72b68b47 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -52,7 +52,7 @@ class PlayerHandoffTest { @Suppress("DEPRECATION") rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } - model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) } @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { @@ -78,15 +78,30 @@ class PlayerHandoffTest { } @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { - val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() + val snapshot = HomeFixture(rule.activity).snapshot("mindel") + val title = snapshot.rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) val view = shell.view view.cards["hero"] = 0 + rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme { + androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { + ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } + PlaybackHandoffVeil(model.session.playback) + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } - awaitModel { navigation.backStack.last() is Destination.Playback } + awaitModel { model.session.playback.launch != null } + assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home) + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(80) + assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home) + rule.mainClock.advanceTimeBy(192) + assertEquals(2, navigation.backStack.size) val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) assertEquals(1_136_274L, destination.positionMs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt index fdcaa710..3b9b2377 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -115,7 +115,7 @@ class WatchReadTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) try { val loading = launch { model.loadLibrary(server, user, person) } runCurrent() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt index 494c8412..8c200d56 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -47,7 +47,7 @@ class WatchReturnTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) val oldFixtures = ConductarrPreference.fixtures ConductarrPreference.updateFixtures(false) try { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 35f10b1e..d1bd3482 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache @@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor( private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, private val conductarr: ConductarrSession, + val session: WolfskinSession, ) : ViewModel() { val conductarrState get() = conductarr.identity val audiences get() = home.audiences @@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor( private var loadRevision = 0L private var catalogueSnapshot = DiscoverSnapshot() private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null + private var playbackJob: kotlinx.coroutines.Job? = null + private var playbackRevision = 0L + + /** Leaving Home invalidates late preparation results as well as a pending black handoff. */ + fun cancelPlayback() { + playbackRevision++ + playbackJob?.cancel() + session.playback.fail() + } suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() @@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor( HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) - is HomeAction.Play -> viewModelScope.launch { - val personKey = shell.person.key - val selectedId = shell.person.jellyfinUserId - val matchesUser = if (selectedId != null) selectedId == user.id.toString() else - shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) - if (!snapshot.jellyfinAvailable || !matchesUser) { - showToast(RecoveryMessage.PlaybackUnavailable) - return@launch - } - try { - val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } - if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) - else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) - } catch (exception: Exception) { - if (exception is CancellationException) throw exception + is HomeAction.Play -> startPlayback(action.title, shell, server, user) + } + } + + private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { + val start = session.playback + if (start.starting != null) return + playbackJob?.cancel() + val revision = ++playbackRevision + val personKey = shell.person.key + start.begin("hero-play") + playbackJob = viewModelScope.launch { + if (revision != playbackRevision || start.starting == null) return@launch + val selectedId = shell.person.jellyfinUserId + val matchesUser = if (selectedId != null) selectedId == user.id.toString() else + personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester) + if (!snapshot.jellyfinAvailable || !matchesUser) { + start.fail() + showToast(RecoveryMessage.PlaybackUnavailable) + return@launch + } + try { + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) } + coroutineContext.ensureActive() + if (revision != playbackRevision || start.starting == null) return@launch + if (item == null) { + start.fail() showToast(RecoveryMessage.PlaybackUnavailable) + } else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) { + start.ready { + if (revision == playbackRevision && shell.person.key == personKey) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + } + } else start.fail() + } catch (exception: Exception) { + if (revision == playbackRevision && start.starting != null) { + start.fail() + if (exception !is CancellationException) showToast(RecoveryMessage.PlaybackUnavailable) } + if (exception is CancellationException) throw exception } } } @@ -234,6 +267,7 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse val identity by model.conductarrState.collectAsState() val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) } + DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 7e39a508..9ba1c4ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -1,6 +1,10 @@ package org.justinvisser.wolfskin.tv import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -12,6 +16,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -64,6 +72,8 @@ class WolfskinSession @Inject constructor() { var serverId: String? = null internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} + var personCommit by mutableStateOf(null) + val playback = org.justinvisser.wolfskin.design.PlaybackStart() } /** The shell routes only through the existing NavigationManager and real settings pages. */ @@ -214,32 +224,48 @@ class WolfskinShellViewModel @Inject constructor( } private fun selectPerson(person: ShellPerson, server: JellyfinServer) { + val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let { + org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key) + } + session.personCommit = commit + session.playback.fail() navigation.replace(Destination.Home()) - if (person.key == signedInPerson) return + if (person.key == signedInPerson) { + commit?.switchEnded = true + return + } identityJob?.cancel() personSwitchJob?.cancel() signedInPerson = "" conductarr.close() libraries.audiences = null personSwitchJob = viewModelScope.launch { - personSwitchMutex.withLock { - val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } - } - if (saved == null || saved.isProtected) { - if (ConductarrPreference.fixtures) return@launch - setupNavigation.navigateTo(SetupDestination.UserList(server)) - return@launch - } - try { - val current = repository.changeUser(server, saved) - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.AppContent(current)) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.UserList(server)) + try { + personSwitchMutex.withLock { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) { + if (!ConductarrPreference.fixtures) { + val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } + if (commit == null) open() else commit.fallback = open + } + return@launch + } + try { + val current = repository.changeUser(server, saved) + coroutineContext.ensureActive() + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + commit?.appReplacementPending = true + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + coroutineContext.ensureActive() + val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } + if (commit == null) open() else commit.fallback = open + } } + } finally { + commit?.switchEnded = true } } } @@ -265,6 +291,8 @@ fun WolfskinApp( viewModel: WolfskinShellViewModel = hiltViewModel(), homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, + titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = + { route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() @@ -294,11 +322,26 @@ fun WolfskinApp( ) RequestsRefresh(state, viewModel::refreshRequests) val onBack = rememberWolfskinBackHandler(state, navigationManager) + val session = viewModel.session + val commit = session.personCommit + LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) { + if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) { + session.personCommit = null + commit.fallback?.invoke() + } + } WolfskinTheme { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, + org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback, org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { - Box(modifier) { + Box(modifier.onPreviewKeyEvent { event -> + if (session.personCommit != null || session.playback.launch != null) true else { + if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail() + false + } + }) { val current = navigationManager.backStack.last() + LaunchedEffect(current, state.person.key) { session.playback.fail() } if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() Backdrop( @@ -309,15 +352,15 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - onBack = onBack, + onBack = { if (session.personCommit == null && session.playback.launch == null) { session.playback.fail(); onBack() } }, + transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry( destination, contentKey = "${destination}_${server.id}_${user.id}", - metadata = if (destination is Destination.Wolfskin && destination.route == "updates") { - org.justinvisser.wolfskin.tv.update.updatesRouteTransitions - } else emptyMap(), ) { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current @@ -335,14 +378,17 @@ fun WolfskinApp( if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { - ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } - } else TitleDestination(route, state, preferences) + ShellSurface(state, handlesContentKeys = false) { + if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) + } + } else titleContent(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { - (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) + (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> + if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, @@ -360,6 +406,16 @@ fun WolfskinApp( } }, ) + org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback, + Modifier.pointerInput(session.playback.launch) { + awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } + }) + if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit) + BackHandler(enabled = session.playback.starting != null && session.playback.launch == null) { + session.playback.fail() + onBack() + } + BackHandler(enabled = commit != null || session.playback.launch != null) { } } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt deleted file mode 100644 index ec5de195..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith -import androidx.navigation3.ui.NavDisplay - -/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */ -internal val updatesRouteTransitions = - NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + - NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + - NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 29ac8a5d..cf33e6db 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -148,10 +148,51 @@ The on state has no unfocused ring: Justin (2026-11-07) read it as active/select Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.” +## UI polish — revision 7 (2026-11-07) + +Justin approved all twelve ideas on the [UI polish page](https://pages.justinvisser.org/wolfskin/ui-polish) and E01–E07 on the [everyday polish page](https://pages.justinvisser.org/wolfskin/ui-polish-everyday): "Do now". He approved the looks and copy exactly as the mocks show. Where this section and an earlier revision disagree, this section wins. + +| Idea | Behaviour | Tokens | +|---|---|---| +| P01 chips | Discover and Library chip fill, text and selected border cross-fade between rest, selected and focused; resting pixels unchanged | `focusMove` 180 ms, `easeOut` | +| P02 toasts | Every recovery toast fades in, holds 3200 ms, then fades out; a new message replaces the text without a second toast | `base` 160 ms, `ease` | +| P03 Sending | The request button keeps its width and reads "Sending…" from OK until the service answers | `base` 160 ms crossfade | +| P04 receipt | The outcome block fades in once when the service answers; the form goes in the same frame | `lateLineFade` 200 ms, `ease` | +| P05 + E03 rows | A row that already entered during this screen visit (including after Back from a pushed page, and after scrolling back) does not enter again; a new visit (tab switch, person switch) and first appearances are unchanged | `rowEnter` / `rowRise` | +| P06 sheets | Requests, Request, Version, Audio and subtitles, Media info and the other right-hand sheets fade with their scrim; one scrim at a time; Menu ↔ sheet hand-offs keep the scrim constant | new `sheetIn` 160 ms / `sheetOut` 100 ms, `easeOut` | +| P07 loading | Requests and picker placeholders breathe on the shared skeleton clock; buttons never pulse | `skeletonCycle`, `breatheEase` | +| P08 person | After OK on a person, the others fade to background, then the picker does, and their Home follows with no sign-in page between | `base` ×2, `ease` | +| P09 readiness | Rows below an expanding or collapsing request slide; the readiness content appears at once and is uncovered by the sliding rows; window shifts still jump | `lateLineFade` 200 ms, `easeOut` | +| P10 empty Requests | "Nothing coming in." / "New requests show up here." / Close (focused) | — | +| P11 Library results | A changed sort or filter result fades up as one: all visible rows together | `rowEnter`, `rowRise` | +| P12 thumbnails | A request without a poster (none, or failed) shows a centred 40 px film or TV glyph in `dim` | new `requests.thumbGlyph` 40 px | +| E01 screen changes | No whole-screen transition anywhere in the shell or the setup host; the incoming screen's own entrance carries the motion | — | +| E02 focus ring | One card ring per shell surface moves between cards, changing x, y, width, height and radius together; card scale and lift unchanged; settled pixels unchanged | `navBlob` spring; `focusMove` alpha | +| E04 Play | Play/Resume reads "Starting…" while the stream is prepared; the page fades to black before the player opens | `base` 160 ms, `ease` | +| E05 scrolling | Every key-driven scroll is a critically damped spring that carries velocity across key repeats and never overshoots | `navBlob` spring | +| E06 progress | Title and card progress fill to the new point after playback; first paint never animates | `heroFade` 320 ms, `easeInOut` | +| E07 seasons | The selection capsule slides between season chips; a season seen before shows its episodes at once | `navBlob` spring | + +Reduced motion (platform animator scale 0 or `LocalReducedMotion`): fades ≤ 80 ms; movement, springs, height and scroll snap; pulses stop. Motion never moves focus ownership; retained exit copies are inert and swallow pointer input. The layer budget is unchanged: one animated layer per shelf; overlays add one bounded panel layer; scrims, veils and the ring are draw-only. + +Decisions taken while specifying, and why: +- P08: the mock's 90 ms pause between the two fades has no token, so the second fade starts as the first ends. +- P09: the request that leaves the bounded 5 → 4 window disappears at the start, as before. +- P10: Close is the approved loading-state pill; the second line uses the `receiptGap` spacing. +- P12: the glyph size needed a new look token. +- By Justin's standing rule ("if we do things similarly elsewhere, assume my instructions likely apply there too"), these also apply: + - E02 covers title-page shelves, cast circles and the person filmography; + - P05/E03 cover Home, Discover and Library alike; + - E07's season chips use P01's colour fade. + +Native proofs: the `ui-polish//` captures listed under Sources and verification. + ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. +Revision-7 polish proofs are listed in `docker/README.md` under UI polish. + `MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point. The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/docker/README.md b/docker/README.md index abe554a0..d912c2fd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1039,6 +1039,23 @@ entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers menu Back/focus, preserved media info and household requests, movie stacking, the Mussolini resume example, request revocation, episode skeleton geometry, and native menu fade frames sampled in 16 ms steps. + +**UI polish (revision 7).** The tests below write 16 ms frame strips and `samples.csv` to `build/wolfskin-captures/ui-polish//`: +- `RequestsPolishTest` (P06 Requests, P07, P09, P10, P12) +- `DiscoverPolishTest` (P01, P03, P04, P06 Request sheet, E03 lanes) +- `LibraryPolishTest` (P01 toolbar, P05, P11) +- `HomePolishRowsTest` (E03) +- `RecoveryToastMotionTest` (P02) +- `FocusGlideTest` (E02) +- `ScrollMomentumTest` (E05) +- `PickerPolishTest` (P07 picker) +- app `TitlePolishTest` (P06 title sheets, E04 title, E06, E07) +- app `PersonCommitTest` (P08) +- app `NavigationCutTest` (E01) +- app `HomeHeroStartingTest` (E04 Home) + +`TitleRound2Test.menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim` now asserts one constant scrim and a monotonic panel fade. `RequestsKeysTest` asserts that OK on the empty Requests Close closes the sheet. + `TitleAsyncFocusTest` delays title and person replies through the real destination and view-model, then exercises content focus and hardware Back. Subtitles Off maps to the player's explicit disabled sentinel, not its unspecified default. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8f3fe345..5b47355f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -17,9 +18,13 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.HomeType as T +/** Home's pending playback belongs to the app session rather than a transient hero composition. */ +val LocalHomePlaybackStart = compositionLocalOf { null } + /** The hero occupies one row while its backdrop extends beneath the first shelves. */ @Composable fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { + val start = LocalHomePlaybackStart.current HeroMotionScope { Box(Modifier.fillMaxWidth().height(L.row_hero)) { if (!row.skeleton) { @@ -57,7 +62,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List val button = measurables.single().measure(constraints) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt new file mode 100644 index 00000000..19a74027 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.clearAndSetSemantics +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S + +/** Frozen public identity data and a frame-clock origin survive replacement of the signed-in app. */ +@Stable +class PersonCommit(val people: List, val chosenIndex: Int, val chosenKey: String) { + var startFrameNanos: Long? = null + internal set + internal var elapsedMillis by mutableFloatStateOf(0f) + var timelineComplete by mutableStateOf(false) + internal set + var switchEnded by mutableStateOf(false) + var appReplacementPending by mutableStateOf(false) + var fallback: (() -> Unit)? = null +} + +/** The frozen picker is opaque and inert; only the covering rectangles change while switching identity. */ +@Composable +fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + val duration = motionDuration(M.base, reduced) + val total = if (reduced) duration else duration * 2 + LaunchedEffect(commit, total) { + while (!commit.timelineComplete) { + withFrameNanos { frame -> + val start = commit.startFrameNanos ?: frame.also { commit.startFrameNanos = it } + commit.elapsedMillis = (frame - start) / 1_000_000f + if (commit.elapsedMillis >= total) commit.timelineComplete = true + } + } + } + Box(modifier.fillMaxSize().background(C.bg).clearAndSetSemantics { } + .pointerInput(commit) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } } + .drawWithContent { + drawContent() + val elapsed = commit.elapsedMillis + if (!reduced) { + val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f)) + val width = L.pickerAvatar.toPx() + val gap = L.pickerGap.toPx() + val left = (size.width - width * commit.people.size - gap * (commit.people.size - 1)) / 2 + commit.people.indices.forEach { index -> + if (index != commit.chosenIndex) drawRect(C.bg.copy(alpha = alpha), + topLeft = Offset(left + index * (width + gap) - gap / 2, S.pickerPeopleTop.toPx() - gap / 2), + size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2)) + } + } + val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration + drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) + }) { + Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) + PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 18e8c341..312c87ea 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -37,15 +37,23 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { @Composable private fun PeoplePicker(state: ShellState) { + PickerPeople(state.people, state.pickerIndex, state.gearFocused, + onPerson = { index -> state.focusPerson(index); state.key(ShellKey.Ok) }, + onGear = { state.gearFocused = true; state.key(ShellKey.Ok) }) +} + +/** Live and frozen pickers share geometry; a frozen picker has no interactive modifiers. */ +@Composable +internal fun PickerPeople(people: List, focusedIndex: Int, gearFocused: Boolean, + onPerson: ((Int) -> Unit)?, onGear: (() -> Unit)?) { Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { - state.people.forEachIndexed { index, person -> - val focused = !state.gearFocused && state.pickerIndex == index - Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { - state.focusPerson(index); state.key(ShellKey.Ok) - }, horizontalAlignment = Alignment.CenterHorizontally) { + people.forEachIndexed { index, person -> + val focused = !gearFocused && focusedIndex == index + Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard) + .then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) Spacer(Modifier.height(S.pickerNameGap)) @@ -54,11 +62,11 @@ private fun PeoplePicker(state: ShellState) { } } val left = (S.stageWidth - L.gear) / 2 - Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) - .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) - if (state.gearFocused) { + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard) + .focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) + .then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) { + Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) + if (gearFocused) { Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) { Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) } @@ -74,7 +82,7 @@ private fun RecoveryPicker(state: ShellState) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } if (loading) { - Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { repeat(state.people.size) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) @@ -103,16 +111,20 @@ private fun RecoveryPicker(state: ShellState) { /** Recovery actions remain focusable while profiles cannot be read. */ @Composable fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, - textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { + textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, + busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) - .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) + .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() } .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { - if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) + val content: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { + if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) + } } + if (busyLabel == null) content() else BusyLabelSwap(busy, busyLabel, if (primary) C.onFocus else C.fg, textStyle, content) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt new file mode 100644 index 00000000..76566c98 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt @@ -0,0 +1,63 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Loading profiles share one pulse without fading the recovery action. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PickerPolishTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun loadingAvatarsBreatheButtonStays() = capture(false) + @Test fun reducedMotionKeepsAvatarsAndButtonStill() = capture(true) + + private fun capture(reduced: Boolean) { + rule.mainClock.autoAdvance = false + val state = ShellState(fixturePeople(), "justin").apply { peopleStatus = PeopleStatus.Loading } + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + } } } + rule.mainClock.advanceTimeBy(32) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P07/${if (reduced) "reduced" else "normal"}").apply { mkdirs() } + val samples = (0..14).map { index -> + val file = File(directory, "picker-loading-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + val pixels = try { image.getPixel(650, 540) to image.getPixel(950, 820) } finally { image.recycle() } + rule.mainClock.advanceTimeBy(100, ignoreFrameDuration = true) + "${index * 100},${pixels.first},${pixels.second}" + } + File(directory, "samples.csv").writeText("timeMs,avatar,button\n" + samples.joinToString("\n")) + val avatars = samples.map { it.split(',')[1].toInt() } + val buttons = samples.map { it.split(',')[2].toInt() } + assertEquals("The recovery button never pulses", 1, buttons.distinct().size) + if (reduced) assertEquals("Reduced motion stops the shared pulse", 1, avatars.distinct().size) + else { + assertNotEquals("The avatar must breathe by the middle of the cycle", avatars.first(), avatars[7]) + assertNotEquals("The avatar must also pass through an intermediate alpha", avatars.first(), avatars[3]) + } + rule.runOnIdle { state.peopleStatus = PeopleStatus.Ready; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithTag("picker-person:justin").assertIsDisplayed() + rule.onNodeWithText("Continue as Justin").assertDoesNotExist() + assertTrue("Removing the last subscriber stops its infinite transition", + androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork }) + } +} -- 2.49.1 From b4742bed6d04f5dcdaacd7e2ac29180000d91b68 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 03:36:19 +0100 Subject: [PATCH 08/17] Observe frame-zero cuts and keep picker masks opaque --- .../wolfskin/tv/HomeHeroStartingTest.kt | 47 +++++--- .../wolfskin/tv/NavigationCutTest.kt | 102 ++++++++++++++---- .../wolfskin/design/PersonCommit.kt | 5 +- 3 files changed, 116 insertions(+), 38 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt index c545c680..8f6868d7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -82,24 +82,34 @@ class HomeHeroStartingTest { private fun successfulStart(reduced: Boolean) { mount(reduced) - val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width - press() - rule.mainClock.advanceTimeBy(32) - rule.onNodeWithText("Starting…").assertIsDisplayed() - assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) - press() - assertTrue(navigation.backStack.single() is Destination.Home) val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) + val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width val samples = mutableListOf() fun frame(phase: String, index: Int): Int { val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + val clock = rule.mainClock.currentTime rule.onRoot().captureRoboImage(file.absolutePath) + rule.waitForIdle() + assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime) val image = BitmapFactory.decodeFile(file.absolutePath) val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size) return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) } - repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() } + press() + val origin = rule.mainClock.currentTime + repeat(14) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + frame("home-start", index) + assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime) + if (index >= 2) { + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) + } + if (index == 3) press() + assertTrue(navigation.backStack.single() is Destination.Home) + } val id = UUID.randomUUID() release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE)) await { session.playback.launch != null } @@ -129,12 +139,20 @@ class HomeHeroStartingTest { @Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() { mount() + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) press() - rule.mainClock.advanceTimeBy(200) - rule.onNodeWithText("Starting…").assertIsDisplayed() + repeat(14) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath) + if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed() + } release.completeExceptionally(IOException("offline")) await { model.toast != null } - rule.mainClock.advanceTimeBy(200) + repeat(14) { index -> + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath) + } rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() assertTrue(navigation.backStack.single() is Destination.Home) @@ -143,6 +161,7 @@ class HomeHeroStartingTest { @Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() { mount() press() + assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting) rule.mainClock.advanceTimeBy(32) rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } rule.mainClock.advanceTimeBy(32) @@ -153,12 +172,14 @@ class HomeHeroStartingTest { assertNull(session.playback.launch) } - private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + private fun press() { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + } private fun await(condition: () -> Boolean) { rule.waitUntil(5000) { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() shadowOf(Looper.getMainLooper()).idle() - if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() condition() } rule.waitForIdle() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index 6f68f7ea..ce6d3a1e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -1,7 +1,9 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import android.graphics.BitmapFactory import android.os.Looper +import androidx.compose.foundation.background import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.* @@ -33,7 +35,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Each navigation change retires the outgoing shell in the first rendered frame. */ +/** Every observed native frame has one opaque screen; the incoming scene must compose within 16 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -57,19 +59,45 @@ class NavigationCutTest { } fun strip(phase: String) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - repeat(21) { index -> - rule.mainClock.advanceTimeByFrame() - val clock = rule.mainClock.currentTime - rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath) - assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) - val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size - val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size - val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size - assertEquals("Only the live destination may own a nav bar", 1, shells) - assertEquals(if (phase == "open-title") 1 else 0, titles) - if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards) - else assertTrue("Back immediately paints Home's shelves", cards > 0) - samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards) + val origin = rule.mainClock.currentTime + var firstIncoming: Int? = null + var opaqueAvatar = 0 + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + rule.waitForIdle() + val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size + val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size + val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size + val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size + val image = BitmapFactory.decodeFile(file.absolutePath) + val avatar = try { image.getPixel(1774, 90) } finally { image.recycle() } + samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar") + assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) + assertEquals("Exactly one destination owns the nav bar in every frame", 1, shells) + assertEquals("No overlap or blank destination during the cut", 1, titles + libraries + if (cards > 0) 1 else 0) + if (index == 0) opaqueAvatar = avatar + assertEquals("The persistent avatar cannot acquire whole-screen transition alpha", opaqueAvatar, avatar) + val incoming = when (phase) { + "open-title" -> titles == 1 + "back" -> cards > 0 + else -> libraries == 1 + } + if (incoming && firstIncoming == null) { + firstIncoming = index + assertTrue("$phase first composed incoming after $index frames (${index * 16} ms), not within one frame", index <= 1) + } + if (firstIncoming != null) { + assertTrue("The incoming destination cannot disappear again", incoming) + if (phase != "back") assertEquals("No outgoing Home card survives the first incoming frame", 0, cards) + else assertEquals("Back cannot retain the title page", 0, titles) + } + } + assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming) + } finally { + File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n")) } } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) } @@ -78,7 +106,6 @@ class NavigationCutTest { strip("back") rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } strip("tab") - File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n")) } @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { @@ -119,6 +146,7 @@ class NavigationCutTest { val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) val stack = mutableStateListOf(SetupDestination.ServerList) + val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt()) val screensaver = mockk() every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { @@ -126,21 +154,49 @@ class NavigationCutTest { mockk(relaxed = true), screensaver, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), setupInterceptor = { destination, modifier -> - androidx.tv.material3.Text(destination.javaClass.simpleName, modifier) + androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + .then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) { + androidx.tv.material3.Text(destination.javaClass.simpleName) + } true }) } } } rule.mainClock.autoAdvance = false + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } + val samples = mutableListOf() for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { val previous = stack.single().javaClass.simpleName + val next = destination.javaClass.simpleName rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - val clock = rule.mainClock.currentTime - val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } - rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath) - assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) - rule.onNodeWithText(previous).assertDoesNotExist() - rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1) + val origin = rule.mainClock.currentTime + var firstIncoming: Int? = null + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + rule.waitForIdle() + val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size } + val image = BitmapFactory.decodeFile(file.absolutePath) + val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } + samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel") + assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) + assertEquals("One setup screen per observed frame, never both or blank", 1, counts.values.sum()) + val incoming = counts.getValue(next) == 1 + assertEquals("Setup screens are opaque, not intermediate-alpha composites", colors.getValue(if (incoming) next else previous), pixel) + if (incoming && firstIncoming == null) { + firstIncoming = index + assertTrue("$next first composed after $index frames (${index * 16} ms), not within one frame", index <= 1) + } + if (firstIncoming != null) { + assertTrue("The incoming setup screen cannot disappear", incoming) + rule.onNodeWithText(previous).assertDoesNotExist() + } + } + assertNotNull("Setup screen did not appear within 320 ms", firstIncoming) + } finally { + File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n")) + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt index 19a74027..aae4fb84 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt @@ -50,6 +50,9 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { .drawWithContent { drawContent() val elapsed = commit.elapsedMillis + val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration + drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) + // Paint the opaque phase-one masks last: phase two must not round their background pixels upward. if (!reduced) { val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f)) val width = L.pickerAvatar.toPx() @@ -61,8 +64,6 @@ fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2)) } } - val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration - drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) }) { Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null) -- 2.49.1 From f05b0de156fe5be05f6f8c5b6a5285d61c904fd0 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 04:00:17 +0100 Subject: [PATCH 09/17] Polish title sheets, playback handoff and season motion --- .../tv/TitlePlaybackNavigationTest.kt | 16 + .../wolfskin/tv/TitlePolishTest.kt | 481 ++++++++++++++++++ .../wolfskin/tv/TitleRound2Test.kt | 11 +- .../wolfskin/tv/TitleThemeMusicTest.kt | 3 + .../wolfskin/tv/TitleDestination.kt | 117 ++++- .../wolfskin/design/PersonPage.kt | 3 +- .../justinvisser/wolfskin/design/TitleCard.kt | 21 +- .../wolfskin/design/TitleControl.kt | 22 +- .../wolfskin/design/TitleHeader.kt | 14 +- .../justinvisser/wolfskin/design/TitleMenu.kt | 16 +- .../wolfskin/design/TitleOptionsSheet.kt | 81 ++- .../justinvisser/wolfskin/design/TitlePage.kt | 102 +++- .../wolfskin/design/TitleState.kt | 11 +- 13 files changed, 820 insertions(+), 78 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 1838f45c..231ffef7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -1,6 +1,9 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences @@ -35,6 +38,7 @@ import java.util.UUID @Config(sdk = [34], application = Application::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class TitlePlaybackNavigationTest { + @get:Rule val rule = createAndroidComposeRule() private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") @@ -110,24 +114,36 @@ class TitlePlaybackNavigationTest { userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) val session = ConductarrSession(MemoryCacheStore()) val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) + rule.mainClock.autoAdvance = false + rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } } + fun finishHandoff() { + rule.waitForIdle() + assertFalse("Preparation alone cannot navigate before black", navigation.backStack.last() is Destination.Playback) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48) + rule.waitForIdle() + } try { model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) navigation.goBack() model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) navigation.goBack() model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) coVerify(exactly = 1) { facade.preparePlayback(seriesId) } } finally { + rule.mainClock.autoAdvance = true model.viewModelScope.cancel() session.close() Dispatchers.resetMain() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt new file mode 100644 index 00000000..d64a6730 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt @@ -0,0 +1,481 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.util.UUID + +/** Native title transitions preserve focus, stream identity and immediate cached content. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitlePolishTest { + @get:Rule val rule = createAndroidComposeRule() + private var model: WolfskinTitleViewModel? = null + private var session: ConductarrSession? = null + private lateinit var state: TitleState + private lateinit var shell: ShellState + private val reduced = mutableStateOf(false) + private val entryActive = mutableStateOf(true) + private val samples = mutableListOf() + private var bitmaps: Map = emptyMap() + private val person = ShellPerson("justin", "Justin", "J", false) + private val id = UUID.fromString("00000000-0000-0000-0000-000000000091") + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + + @After fun dispose() { model?.viewModelScope?.cancel(); session?.close() } + private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + private fun prepareShell() { + shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(id.toString())); navFocused = false } + } + private fun render(data: TitlePageData = fixture().title("Locke", "justin"), reducedMotion: Boolean = false) { + reduced.value = reducedMotion + prepareShell() + state = TitleState(data, person) { } + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) + } } } + rule.waitForIdle() + rule.mainClock.advanceTimeBy(600) + rule.mainClock.autoAdvance = false + } + private fun mountModel(facade: TitleReadFacade, data: TitlePageData, reducedMotion: Boolean) : NavigationManager { + reduced.value = reducedMotion + prepareShell() + coEvery { facade.readSaved(id, any()) } returns null + coEvery { facade.read(id, any(), any()) } returns data + val navigation = NavigationManager().apply { navigateTo(Destination.Wolfskin(titleRoute(id.toString()))) } + session = ConductarrSession(MemoryCacheStore()) + model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session!!, + mockk(relaxed = true), mockk(relaxed = true)) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme { + val route = titleRoute(id.toString()) + ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) { + TitleDestination(route, shell, preferences, Modifier.requiredSize(960.dp, 540.dp), model!!, fixtures = false) + } + } } } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); model!!.title?.data?.status == TitleReadStatus.Ready } + state = model!!.title!! + rule.waitForIdle() + rule.mainClock.advanceTimeBy(600) + rule.mainClock.autoAdvance = false + return navigation + } + private fun tick() { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + private fun path(idea: String, name: String, index: Int): File = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), + "ui-polish/$idea/${if (reduced.value) "reduced-" else ""}$name-${index.toString().padStart(2, '0')}.png").also { requireNotNull(it.parentFile).mkdirs() } + private fun frame(idea: String, name: String, index: Int, sample: (android.graphics.Bitmap) -> Int): Int { + val file = path(idea, name, index) + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + return try { sample(bitmap).also { samples.add("$name,${index * 16},$it") } } finally { bitmap.recycle() } + } + private fun brightness(bitmap: android.graphics.Bitmap, x: Int, y: Int): Int = bitmap.getPixel(x, y).let { + android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) + } + private fun saveSamples(idea: String) { + File(path(idea, "samples", 0).parentFile, "${if (reduced.value) "reduced-" else ""}${samples.first().substringBefore(',')}-samples.csv") + .writeText("phase,timeMs,value\n" + samples.joinToString("\n")) + } + private fun monotonic(values: List) { + assertNotEquals("Motion changes visible pixels", values.first(), values.last()) + val direction = if (values.last() > values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("No reversal: $values", (after - before) * direction >= -1) } + } + + @Test fun versionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(false) + @Test fun reducedVersionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(true) + private fun versionSheet(reducedMotion: Boolean) { + render(fixture().title("Locke", "justin"), reducedMotion) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "version" }) } + rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.DirectionCenter) } + val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetIn + val scrim = mutableListOf() + val panel = mutableListOf() + repeat(duration / 16 + 3) { index -> + frame("P06", "title-version", index) { bitmap -> + scrim.add(brightness(bitmap, 400, 900)); brightness(bitmap, 1900, 900).also { panel.add(it) } + } + tick() + } + monotonic(scrim); monotonic(panel) + val opener = state.column + rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Escape) } + val owner = hasTestTag("title-page") or hasTestTag("title-option-version") + val closing = mutableListOf() + repeat((if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetOut) / 16 + 3) { index -> + closing.add(frame("P06", "title-version-out", index) { brightness(it, 1900, 900) }) + if (index == 2) { + rule.waitForIdle() + rule.runOnIdle { } + rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists() + rule.onNodeWithTag("title-sheet", useUnmergedTree = true) + .assert(SemanticsMatcher.keyNotDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused)) + rule.onAllNodes(hasAnyAncestor(hasTestTag("title-sheet")) and + SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused), useUnmergedTree = true).assertCountEquals(0) + } + assertNull(state.sheet); assertEquals(opener, state.column); assertFalse(shell.navFocused) + tick() + } + monotonic(closing) + rule.onNodeWithTag("title-sheet").assertDoesNotExist() + rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists() + saveSamples("P06") + } + + private fun pendingPlayback(reducedMotion: Boolean): Triple, NavigationManager> { + val facade = mockk() + val prepared = CompletableDeferred() + coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() } + val data = fixture().title("Locke", "justin") + val target = data.item.copy(id = id.toString()) + val navigation = mountModel(facade, data.copy(item = target, playTarget = target, watchTarget = target, + related = listOf(TitleItem("other", "Other title", "movie"))), reducedMotion) + return Triple(facade, prepared, navigation) + } + + @Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = playback(false) + @Test fun reducedPlaySaysStartingThenFadesToBlackBeforeThePlayer() = playback(true) + private fun playback(reducedMotion: Boolean) { + val (facade, prepared, navigation) = pendingPlayback(reducedMotion) + val resting = rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot + rule.runOnIdle { state.click("header", 0) } + repeat(13) { index -> frame("E04", "title-start", index) { brightness(it, 100, 900) }; tick() } + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(resting.width, rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot.width, .1f) + rule.runOnIdle { state.click("header", 0) } + coVerify(exactly = 1) { facade.preparePlayback(id) } + rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.playbackStart.launch != null } + assertFalse(navigation.backStack.last() is Destination.Playback) + val values = mutableListOf() + val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base + repeat(duration / 16 + 3) { index -> + values.add(frame("E04", "title-veil", index) { brightness(it, 100, 900) }) + if (index * 16 < duration) assertFalse(navigation.backStack.last() is Destination.Playback) + tick() + } + assertEquals(Destination.Playback(id, 0L), navigation.backStack.last()) + assertEquals(1, navigation.backStack.count { it is Destination.Playback }) + assertTrue("The handoff reaches black before navigation", values.contains(0)) + assertNull(state.playbackStart.starting) + rule.runOnIdle { navigation.goBack() } + coEvery { facade.preparePlayback(id) } throws IllegalStateException("offline") + rule.runOnIdle { state.click("header", 0) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.PlaybackUnavailable } + repeat(14) { tick() } + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + assertFalse(navigation.backStack.last() is Destination.Playback) + saveSamples("E04") + } + + @Test fun progressFillsToTheNewPointAfterPlayback() = progress(false) + @Test fun reducedProgressSnapsToTheNewPointAfterPlayback() = progress(true) + @Test fun lateRuntimeMetadataAnimatesAnExistingResumeTrack() = progress(false, runtimeKnown = false) + @Test fun reducedLateRuntimeMetadataSnapsAnExistingResumeTrack() = progress(true, runtimeKnown = false) + private fun progress(reducedMotion: Boolean, runtimeKnown: Boolean = true) { + val data = fixture().title("Locke", "justin") + val target = data.item.copy(runtimeSeconds = if (runtimeKnown) 1000 else 0, resumeSeconds = 134) + val from = if (runtimeKnown) .134f else 0f + val phase = if (runtimeKnown) "title-progress" else "title-progress-late-runtime" + render(data.copy(playTarget = target), reducedMotion) + val bounds = rule.onNodeWithTag("title-progress").fetchSemanticsNode().boundsInRoot + val width = (WolfskinTokens.Title.progressWidth.value * 2).toInt() + fun edge(bitmap: android.graphics.Bitmap): Int = (0 until width).lastOrNull { x -> + bitmap.getPixel(bounds.left.toInt() + x, bounds.center.y.toInt()) == WolfskinTokens.Color.accent.toArgb() + }?.plus(1) ?: 0 + val initial = frame("E06", "$phase-initial", 0, ::edge) + assertEquals(width * from, initial.toFloat(), 1f) + rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(runtimeSeconds = 1000, resumeSeconds = 419))) } + val values = List(23) { index -> + frame("E06", phase, index, ::edge).also { + if (index == 1) rule.onNodeWithText("10 min left").assertIsDisplayed() + tick() + } + } + assertEquals(width * .419f, values.last().toFloat(), 1f) + if (reducedMotion) assertEquals(values.last(), values[1]) else { + monotonic(values) + assertEquals(width * (from + .419f) / 2f, values[11].toFloat(), width * .06f) + } + rule.onNodeWithText("10 min left").assertIsDisplayed() + rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(id = "other", runtimeSeconds = 1000, resumeSeconds = 800))) } + tick() + assertEquals(width * .8f, frame("E06", "title-new-target", 0, ::edge).toFloat(), 1f) + saveSamples("E06") + } + + @Test fun cardProgressFillsAfterPlaybackWithoutAnimatingItsFirstPaint() = cardProgress(false) + @Test fun reducedCardProgressSnapsAfterPlayback() = cardProgress(true) + private fun cardProgress(reducedMotion: Boolean) { + reduced.value = reducedMotion + val title = HomeTitle("card", "Card", "movie", runtimeSeconds = 1000, resumeSeconds = 134.0, held = true) + val row = mutableStateOf(HomeRow("resume", HomeRowKind.Shelf, cards = listOf(title))) + val memory = HomeViewMemory() + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { HomeShelf(row.value, memory, false, 0, emptyList(), {}, {}) } + } } } + rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false + val bounds = rule.onNodeWithTag("home-card:resume:0").fetchSemanticsNode().boundsInRoot + val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt() + fun edge(bitmap: android.graphics.Bitmap): Int = (0 until bounds.width.toInt()).lastOrNull { x -> + bitmap.getPixel(bounds.left.toInt() + x, y) == WolfskinTokens.Color.accent.toArgb() + }?.plus(1) ?: 0 + assertEquals(bounds.width * .134f, frame("E06", "card-progress-initial", 0, ::edge).toFloat(), 1f) + rule.runOnIdle { row.value = row.value.copy(cards = listOf(title.copy(resumeSeconds = 419.0))) } + val values = List(23) { index -> frame("E06", "card-progress", index, ::edge).also { tick() } } + assertEquals(bounds.width * .419f, values.last().toFloat(), 1f) + if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values) + saveSamples("E06") + } + + @Test fun seasonCapsuleGlidesAndCachedSeasonSkipsSkeleton() = seasons(false) + @Test fun reducedSeasonCapsuleSnapsAndCachedSeasonSkipsSkeleton() = seasons(true) + private fun seasons(reducedMotion: Boolean) { + val facade = mockk() + val series = TitleItem(id.toString(), "Series", "tv", held = true, seasons = 6) + fun image(color: Int) = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888) + .apply { eraseColor(color) }.asImageBitmap() + bitmaps = mapOf("cache:fifth" to image(android.graphics.Color.RED), "cache:sixth" to image(android.graphics.Color.GREEN)) + val fifth = TitleItem("fifth", "Cached episode", "episode", season = 5, episode = 1, backdrop = "cache:fifth") + val sixth = fifth.copy(id = "sixth", name = "New episode", season = 6, backdrop = "cache:sixth") + val refresh = CompletableDeferred() + val data = TitlePageData(series, selectedSeason = 5, episodes = listOf(fifth)) + val navigation = mountModel(facade, data, reducedMotion) + coEvery { facade.read(id, 6, any()) } returns data.copy(selectedSeason = 6, episodes = listOf(sixth)) + coEvery { facade.read(id, 5, any()) } coAnswers { refresh.await() } + rule.runOnIdle { state.focus("seasons", 4) } + repeat(25) { tick() } + rule.runOnIdle { state.click("seasons", 5); state.focus("seasons", 0) } + val row = rule.onNode(hasText("Season 6") and hasClickAction()).fetchSemanticsNode().boundsInRoot + fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x -> + val pixel = bitmap.getPixel(x, (row.top + 4).toInt()) + android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230 + } ?: -1 + val values = List(23) { index -> frame("E07", "season", index, ::left).also { tick() } } + assertTrue(values.all { it >= 0 }) + if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values) + values.forEach { assertTrue("No capsule overshoot", it <= values.last() + 1) } + rule.runOnIdle { state.click("seasons", 4) } + assertEquals("Cached episodes are installed synchronously", listOf(fifth), state.data.episodes) + assertEquals(TitleReadStatus.Ready, state.data.episodesStatus) + tick() + // Frame 0 may retain the outgoing real cards; the first incoming subtree is due by frame 1. + repeat(4) { index -> + assertEquals(listOf(fifth), state.data.episodes) + assertEquals(TitleReadStatus.Ready, state.data.episodesStatus) + val slot = hasTestTag("title-episode-slot-0") + rule.onAllNodes(slot, useUnmergedTree = true).assertCountEquals(1) + rule.onNode(slot and hasAnyDescendant(hasClickAction()), useUnmergedTree = true).assertExists() + rule.onAllNodes(hasText("Cached episode") or hasText("New episode"), useUnmergedTree = true).assertCountEquals(1) + val bounds = rule.onNode(slot, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val pixel = frame("E07", "cached-return", index) { bitmap -> + val pixels = (1..3).map { part -> + bitmap.getPixel((bounds.left + bounds.width * part / 4).toInt(), (bounds.top + 24).toInt()) + } + assertTrue("Every frame has opaque real episode art, never a skeleton, blank row or partial alpha", + pixels.all { it == android.graphics.Color.RED || it == android.graphics.Color.GREEN }) + assertEquals("The episode artwork stays uniformly opaque", 1, pixels.distinct().size) + pixels.first() + } + if (index >= 1) { + assertEquals("The first incoming frame paints cached Season 5", android.graphics.Color.RED, pixel) + rule.onNode(hasText("Cached episode") and hasAnyAncestor(slot), useUnmergedTree = true).assertExists() + } + tick() + } + rule.onNode(hasClickAction() and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true) + .performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + assertEquals(Destination.Wolfskin(titleRoute(fifth.id)), navigation.backStack.last()) + rule.runOnIdle { navigation.goBack() } + rule.runOnIdle { refresh.completeExceptionally(IllegalStateException("offline")) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.StillUnreachable } + assertEquals(listOf(fifth), state.data.episodes) + saveSamples("E07") + } + + @Test fun titleScrollKeepsTheCastPinnedWithoutChangingFocus() = titleScroll(false) + @Test fun reducedTitleScrollSnapsWithoutChangingFocus() = titleScroll(true) + private fun titleScroll(reducedMotion: Boolean) { + val data = fixture().title("Andor", "justin") + render(data.copy(episodes = data.episodes.distinctBy { it.id }), reducedMotion) + rule.runOnIdle { state.focus("cast", 0) } + val positions = mutableListOf() + repeat(26) { index -> + frame("E05", "title-scroll", index) { brightness(it, 400, 900) } + val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "cast" }?.offset + if (offset != null) positions.add(offset) + tick() + } + rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress } + rule.waitForIdle() + val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "cast" }.offset + assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled) + if (reducedMotion) assertEquals(positions.last(), positions.first()) + else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) } + assertEquals("cast", state.row); assertEquals(0, state.column); assertFalse(shell.navFocused) + saveSamples("E05") + } + + @Test fun personScrollKeepsTheSelectedCreditRowPinned() = personScroll(false) + @Test fun reducedPersonScrollSnapsToTheSelectedCreditRow() = personScroll(true) + private fun personScroll(reducedMotion: Boolean) { + reduced.value = reducedMotion + prepareShell() + val person = PersonState(TitlePerson("actor", "Actor"), List(30) { TitleItem("credit-$it", "Credit $it", "movie") }, onAction = { }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + PersonSurface(person, shell, Modifier.requiredSize(960.dp, 540.dp)) + } } } + rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false + rule.runOnIdle { person.index = 24 } + val positions = mutableListOf() + repeat(26) { index -> + frame("E05", "person-scroll", index) { brightness(it, 400, 900) } + val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "credit-24" }?.offset + if (offset != null) positions.add(offset) + tick() + } + rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress } + rule.waitForIdle() + val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "credit-24" }.offset + assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled) + if (reducedMotion) assertEquals(positions.last(), positions.first()) + else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) } + assertEquals(24, person.index); assertFalse(shell.navFocused) + saveSamples("E05") + } + + @Test fun cardFirstPaintUsesTheCurrentFractionBeforeAnyAnimationFrame() { + rule.mainClock.autoAdvance = false + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { + TitleCard(false, Modifier.testTag("first-card"), progress = .134f) { } + } + } } + rule.waitForIdle() + val bounds = rule.onNodeWithTag("first-card").fetchSemanticsNode().boundsInRoot + val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt() + val edge = frame("E06", "card-first-paint", 0) { bitmap -> + (0 until bounds.width.toInt()).lastOrNull { bitmap.getPixel(bounds.left.toInt() + it, y) == WolfskinTokens.Color.accent.toArgb() }?.plus(1) ?: 0 + } + assertEquals(bounds.width * .134f, edge.toFloat(), 1f) + } + + @Test fun seasonCapsuleRetargetsWithoutStoppingBetweenChoices() = retargetSeason(false) + @Test fun reducedSeasonCapsuleRetargetsInOneFrame() = retargetSeason(true) + private fun retargetSeason(reducedMotion: Boolean) { + val series = TitleItem("repeat", "Series", "tv", held = true, seasons = 8) + render(TitlePageData(series, selectedSeason = 3), reducedMotion) + rule.runOnIdle { state.focus("seasons", 0) } + repeat(25) { tick() } + val row = rule.onNode(hasText("Season 5") and hasClickAction()).fetchSemanticsNode().boundsInRoot + fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x -> + val pixel = bitmap.getPixel(x, (row.top + 4).toInt()) + android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230 + } ?: -1 + rule.runOnIdle { state.click("seasons", 3); state.focus("seasons", 0) } + val first = List(6) { index -> frame("E07", "season-repeat-first", index, ::left).also { tick() } } + rule.runOnIdle { state.click("seasons", 4); state.focus("seasons", 0) } + val second = List(23) { index -> frame("E07", "season-repeat-second", index, ::left).also { tick() } } + assertTrue(first.all { it >= 0 } && second.all { it >= 0 }) + if (reducedMotion) assertEquals(second.last(), second[1]) else { + val before = first.last() - first[first.lastIndex - 1] + val after = second[2] - second[1] + assertTrue("A moving capsule cannot restart from zero velocity: before=$before, after=$after", before > 0 && after >= before * .75f) + second.zipWithNext().forEach { (previous, next) -> assertTrue(next >= previous - 1) } + } + saveSamples("E07") + } + + @Test fun backCancelsPreparationWithoutALateFailureToast() = cancelPreparation(false) + @Test fun reducedBackCancelsPreparationWithoutALateFailureToast() = cancelPreparation(true) + private fun cancelPreparation(reducedMotion: Boolean) { + val (_, prepared, navigation) = pendingPlayback(reducedMotion) + rule.runOnIdle { state.click("header", 0) } + tick() + rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "version" }) } + tick() + rule.runOnIdle { state.back(shell) } + assertNull(state.playbackStart.starting) + assertNull(state.sheet) + rule.runOnIdle { prepared.completeExceptionally(IllegalStateException("late failure")) } + repeat(24) { tick() } + assertNull(state.toast) + assertFalse(navigation.backStack.last() is Destination.Playback) + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertDoesNotExist() + } + + @Test fun leavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(false) + @Test fun reducedLeavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(true) + private fun cancelOwnership(reducedMotion: Boolean) { + val (facade, initial, navigation) = pendingPlayback(reducedMotion) + var prepared = initial + coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() } + for (inactive in listOf(true, false)) { + rule.runOnIdle { state.click("header", 0) } + tick() + rule.runOnIdle { if (inactive) entryActive.value = false else state.click("related", 0) } + tick() + rule.mainClock.advanceTimeUntil(timeoutMillis = 80) { + !rule.onNodeWithTag("title-page").fetchSemanticsNode().config + .contains(androidx.compose.ui.semantics.SemanticsProperties.Focused) + } + val destination = navigation.backStack.last() + assertNull("The rendered entry has lost ownership (inactive=$inactive)", state.playbackStart.starting) + rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) } + repeat(24) { tick() } + assertEquals("A late prepared stream cannot take over the new page", destination, navigation.backStack.last()) + assertFalse(destination is Destination.Playback) + assertNull(state.toast) + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.runOnIdle { + if (inactive) { entryActive.value = true; prepared = CompletableDeferred() } else navigation.goBack() + } + tick() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index 12aaadf2..10842787 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -197,10 +197,10 @@ class TitleRound2Test { render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } menu() rule.mainClock.autoAdvance = false - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() } rule.runOnIdle { state.clickSheet(0) } var waited = 0 - while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) { + while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) { rule.mainClock.advanceTimeByFrame() rule.waitForIdle() waited += 16 @@ -212,7 +212,7 @@ class TitleRound2Test { val bitmap = BitmapFactory.decodeFile(path.absolutePath) fun brightness(x: Int, y: Int): Int { val pixel = bitmap.getPixel(x, y) - return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + return (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3 } return try { (brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") } @@ -222,7 +222,10 @@ class TitleRound2Test { rule.waitForIdle() frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } } - info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) } + info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) } + info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) } + assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first) + assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first) rule.runOnIdle { state.back(shell) } repeat(14) { index -> rule.mainClock.advanceTimeBy(16) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt index f2bcdf90..fb98bd3a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt @@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.tv.read.TitleReadFacade import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -175,6 +176,8 @@ class TitleThemeMusicTest { assertEquals(listOf("play:$seriesId"), playback.events) playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) } rule.runOnIdle { titleModel.title!!.click("header", 0) } + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48) + rule.waitForIdle() rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback } playback.onStop = {} rule.waitForIdle() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index aeb7f687..0bd7253d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -83,19 +84,38 @@ class WolfskinTitleViewModel @Inject constructor( private var personReadPending = false private var personReadFinished = false private var loadJob: Job? = null + private var playbackJob: Job? = null + private var playbackGeneration = 0 + private val seasonEpisodes = mutableMapOf>() private val saveMutex = Mutex() private var performerPage = 1 var conductarrAction: ((TitleAction) -> Unit)? = null val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" - fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) - fun popRoute() = navigation.goBack() + fun openRoute(route: String) { + cancelPlayback() + navigation.navigateTo(Destination.Wolfskin(route)) + } + fun popRoute() { + cancelPlayback() + navigation.goBack() + } + /** Preparation belongs to the active entry, including when Back closes an overlay. */ + internal fun cancelPlayback() { + playbackJob?.cancel() + playbackJob = null + title?.playbackStart?.fail() + } private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/") + if (!sameTitle) { + cancelPlayback() + seasonEpisodes.clear() + } val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG } if (force && readyTitle && fixture == null && @@ -117,6 +137,7 @@ class WolfskinTitleViewModel @Inject constructor( preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) if (fixtureRefresh == null) { + cancelPlayback() // Losing authorization clears the old data, but is not another first-load skeleton. val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle) @@ -140,13 +161,26 @@ class WolfskinTitleViewModel @Inject constructor( hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), ) if (fixtureRefresh != null) { - if (title === fixtureRefresh) fixtureRefresh.update(restored) - } else title = TitleState(restored, viewer, ::handle) + if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored) + } else { + title = TitleState(restored, viewer, ::handle) + rememberSeason(restored) + } } } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) } } + private fun rememberSeason(data: TitlePageData) { + if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes } + } + + private fun updateTitle(current: TitleState, data: TitlePageData) { + if (title !== current) return + current.update(data) + rememberSeason(data) + } + private suspend fun readTitle(id: String, retry: Boolean = false) { val current = title ?: return val uuid = id.toUuidOrNull() @@ -157,9 +191,9 @@ class WolfskinTitleViewModel @Inject constructor( val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } if (title !== current) return if (heldId != null) { - if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) } held = facade.read(heldId, current.requestedSeason, preferences) - current.update(held) + updateTitle(current, held) } } catch (exception: Exception) { if (exception is CancellationException) throw exception @@ -174,7 +208,7 @@ class WolfskinTitleViewModel @Inject constructor( if (identity != null && tmdb != null) { val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> - if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) + if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data) } } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) } @@ -253,9 +287,13 @@ class WolfskinTitleViewModel @Inject constructor( private fun handle(action: TitleAction) { when (action) { - TitleAction.Pop -> navigation.goBack() - is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + TitleAction.Pop -> popRoute() + is TitleAction.Person -> { + cancelPlayback() + navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + } is TitleAction.OpenTitle -> { + cancelPlayback() val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE @@ -267,7 +305,7 @@ class WolfskinTitleViewModel @Inject constructor( is TitleAction.Play -> { val opened = title?.data?.watchTarget play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), - restart = action.restart) + restart = action.restart, control = if (action.restart) "restart" else "play") } is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) else { @@ -276,16 +314,21 @@ class WolfskinTitleViewModel @Inject constructor( } is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { + val current = title ?: return loadJob?.cancel() + val cached = seasonEpisodes[action.number] + if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached) loadJob = viewModelScope.launch { - val current = title ?: return@launch - current.showSeasonLoading() val source = fixture if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) - else { + else try { val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } - current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, + updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() + current.toast = RecoveryMessage.StillUnreachable } } } @@ -304,19 +347,33 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, restart: Boolean) { - val current = title + private fun play(id: UUID?, restart: Boolean, control: String = "restart") { + val current = title ?: return + if (current.playbackStart.starting != null) return + val generation = ++playbackGeneration + playbackJob?.cancel() + current.playbackStart.begin(control) val playbackScope = watchScope - viewModelScope.launch { + playbackJob = viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") - if (title !== current || watchScope != playbackScope) return@launch - themeSongPlayer.stop() - navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + if (generation != playbackGeneration) return@launch + if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) { + current.playbackStart.fail() + return@launch + } + current.playbackStart.ready { + if (generation == playbackGeneration && title === current && watchScope == playbackScope) { + themeSongPlayer.stop() + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + } + } } catch (exception: Exception) { + val active = generation == playbackGeneration && current.playbackStart.starting != null + if (active) current.playbackStart.fail() if (exception is CancellationException) throw exception - title?.toast = RecoveryMessage.PlaybackUnavailable + if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable } } } @@ -358,20 +415,30 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen val watchRefresh = rememberWatchRefresh(viewModel.watchScope) LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } val title = viewModel.title?.takeIf { it.person.key == shell.person.key } + val active = isScreenEntryActive() + DisposableEffect(viewModel, title, active) { + if (!active) viewModel.cancelPlayback() + onDispose { viewModel.cancelPlayback() } + } TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer, - active = isScreenEntryActive() && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, + active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, owner = "${shell.person.key}:${viewModel.watchScope}") val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = isScreenEntryActive() && title != null) { - viewModel.handleBack(route, shell) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + BackHandler(enabled = active && title != null) { + if (title?.playbackStart?.launch == null) { + viewModel.cancelPlayback() + viewModel.handleBack(route, shell) { + if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + } } } Box(modifier.onPreviewKeyEvent { event -> + if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true + if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback() if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) true diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index c6d45ab7..80507be8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) } val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset - if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) + if (target != current) animateFrameScroll(list, target - current) } Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } .titleWheel { wheelPending = true; shell.navFocused = false } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index ea760054..cacef3f0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -1,5 +1,11 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -33,6 +39,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) + val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) @@ -51,9 +58,21 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs val top = Offset(0f, size.height - edge - fill) if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) drawRect(WolfskinTokens.Color.accent, top, - Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) + Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill)) } } }, content = content) } } + +/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */ +@Composable +internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State { + val reduced = reducedMotion() + val progress = remember(identity, hasResumeLine) { Animatable(fraction) } + LaunchedEffect(progress, fraction, reduced) { + if (reduced) progress.snapTo(fraction) + else progress.animateTo(fraction, tween(WolfskinTokens.Motion.heroFade, easing = WolfskinTokens.Motion.easeInOut)) + } + return if (reduced) rememberUpdatedState(fraction) else progress.asState() +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 2441c8ed..ab52201a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -25,24 +26,35 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D +/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */ +internal val LocalTitleControlsEnabled = compositionLocalOf { true } + /** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ @Composable -fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { +fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current + val enabled = LocalTitleControlsEnabled.current val background = if (option.primary) C.fg else if (overArtwork) { if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val foreground = if (option.primary) C.onFocus else C.fg val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) - Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) + Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(background, shape) - .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), + .clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions), horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { - if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) - if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) { + Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon)) + Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + } + } else { + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) + if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index f7cfe54f..caa84a14 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.testTag @@ -13,7 +15,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -86,10 +87,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target -> Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { if (target.resumeSeconds > 0) { - Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { - val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f - Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) - } + val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f + val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true) + Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress)) + .drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) }) Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) } @@ -97,7 +98,8 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, - onFocus = { state.focus("header", index) }) { state.click("header", index) } } + onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play", + busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } } } if (state.options.any { it.icon }) { val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt index 64e848e1..080262ae 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { LaunchedEffect(visible, reduced) { if (visible) focus.requestFocus() opacity.animateTo(if (visible) 1f else 0f, - tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut)) } if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) { Box(Modifier.fillMaxSize().background(C.sheetScrim) - .clickable { if (visible) state.back(shell) } + .clickable(enabled = visible) { state.back(shell) } + .pointerInput(visible) { + if (!visible) awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { + }.focusable(enabled = visible)) { + CompositionLocalProvider(LocalTitleControlsEnabled provides visible) { Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth) .graphicsLayer { alpha = opacity.value } .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) @@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { } } } + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 49039828..c1e01ab1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -1,5 +1,11 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable @@ -44,25 +50,65 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Sheets sit above navigation and consume pointer and key input across the entire scrim. */ @Composable fun TitleOptionsSheet(state: TitleState, shell: ShellState) { + val visible = state.sheet != null && state.sheet != TitleSheet.Menu + val menu = state.sheet == TitleSheet.Menu + val previous = remember { arrayOf(null) } + val retainedSheet = remember { arrayOf(null) } + val retained = remember(state) { TitleState(state.data, state.person) { } } + val panelScrim = remember { booleanArrayOf(false) } + val ownsScrim = visible || (panelScrim[0] && !menu) + val fromMenu = visible && previous[0] == TitleSheet.Menu + val fade = rememberSheetFade(visible) + val scrim = remember { Animatable(0f) } + val reduced = reducedMotion() val focus = remember { FocusRequester() } - LaunchedEffect(state.sheet) { focus.requestFocus() } + SideEffect { + previous[0] = state.sheet + if (visible || menu) panelScrim[0] = visible + if (visible) { + retainedSheet[0] = state.sheet + retained.update(state.data) + retained.sheetIndex = state.sheetIndex + retained.versionId = state.versionId + retained.audioIndex = state.audioIndex + retained.subtitleIndex = state.subtitleIndex + retained.watched = state.watched + } + } + LaunchedEffect(visible, menu, reduced) { + if (visible) focus.requestFocus() + if (menu || fromMenu) scrim.snapTo(1f) + if (!menu) scrim.animateTo(if (visible) 1f else 0f, + tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + } + if (!visible && fade.value == 0f && (!ownsScrim || scrim.value == 0f)) return + val sheet = if (visible) state.sheet else retainedSheet[0] + val content = if (visible) state else retained val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } - .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } + Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value } + .pointerInput(visible) { + awaitPointerEventScope { + while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + .testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) - key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { - CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) - .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), - gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { - when (state.sheet) { - TitleSheet.Version -> VersionChoices(state) - TitleSheet.Tracks -> TrackChoices(state) - TitleSheet.MediaInfo -> MediaInformation(state) - TitleSheet.ConfirmWatched -> WatchedConfirmation(state) - TitleSheet.HiddenReceipt -> HiddenReceipt(state) - TitleSheet.Menu, null -> Unit + visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable(enabled = visible)) { + CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) { + CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight() + .dropShadow(RectangleShape, shadow).background(C.surface) + .verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), + gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { + when (sheet) { + TitleSheet.Version -> VersionChoices(content) + TitleSheet.Tracks -> TrackChoices(content) + TitleSheet.MediaInfo -> MediaInformation(content) + TitleSheet.ConfirmWatched -> WatchedConfirmation(content) + TitleSheet.HiddenReceipt -> HiddenReceipt(content) + TitleSheet.Menu, null -> Unit + } } } } @@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) { @Composable private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + val enabled = LocalTitleControlsEnabled.current val foreground = if (focused) C.onFocus else C.fg val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) - Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) + Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), + .clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { CssColumn(Modifier.weight(1f)) { Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 81fb0ce0..9cc90fb0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester @@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi val view = screenView(shell) LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> + if (state.playbackStart.launch != null) return@onPreviewKeyEvent true // No content target exists during an initial load; Down must not enter a skeleton. if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false else when (readShellKey(event)) { @@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } } - if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell) TitleMenu(state, shell) + if (!shell.pickerVisible) TitleOptionsSheet(state, shell) if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } + PlaybackHandoffVeil(state.playbackStart) } } @@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism LaunchedEffect(message) { if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } } - if (message != null) RecoveryToast(message, modifier) + RecoveryToastHost(message, modifier) } /** One keyed vertical frame renders the exact header, season, cast and related order. */ @@ -92,6 +108,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val entryActive = LocalScreenActive.current var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } + val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } val compactHeader = state.data.status == TitleReadStatus.Ready val item = state.data.item @@ -99,7 +116,12 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) { - if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() + val restoring = sheetWasOpen[0] && state.sheet == null + sheetWasOpen[0] = state.sheet != null + if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) { + if (restoring) withFrameNanos { } + focus.requestFocus() + } } val displayedRows = remember(state.data) { buildList { @@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f else y - with(density) { L.pinnedLine.toPx() } - if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) + if (target != position) animateFrameScroll(list, target - position) } val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } @@ -197,13 +219,66 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current val list = androidx.compose.foundation.lazy.rememberLazyListState() - val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) - LaunchedEffect(visibleChip, state.data.seasonNumbers) { + val reduced = reducedMotion() + val selectedIndex = state.data.seasonNumbers.indexOf(state.season) + val visibleChip = if (active) state.column else selectedIndex + LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) { if (visibleChip >= 0) { - if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip) + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip } + if (visible == null) list.scrollToItem(visibleChip) + else animateFrameScroll(list, visible.offset.toFloat()) } } - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + var capsuleIndex by remember { mutableIntStateOf(selectedIndex) } + val capsulePosition = remember { Animatable(0f) } + val capsuleWidth = remember { Animatable(0f) } + // A stable virtual row coordinate preserves the spring's frame time and velocity on retarget. + var capsuleTarget by remember { mutableFloatStateOf(0f) } + val selection = rememberUpdatedState(selectedIndex) + LaunchedEffect(list, reduced) { + snapshotFlow { selection.value }.collectLatest { index -> + val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first() + val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex } + if (reduced || capsuleWidth.value == 0f || previous == null) { + capsulePosition.snapTo(0f) + capsuleWidth.snapTo(target.size.toFloat()) + capsuleTarget = 0f + capsuleIndex = index + } else { + val targetPosition = capsuleTarget + target.offset - previous.offset + capsuleTarget = targetPosition + capsuleIndex = index + launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) } + if (capsuleWidth.targetValue != target.size.toFloat()) launch { + capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) + } + } + } + } + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache { + val height = D.chipHeight.toPx() + val radius = WolfskinTokens.Radius.chip.toPx() + val border = D.chipBorder.toPx() + val stroke = Stroke(border) + val inset = Offset(border / 2, border / 2) + val outerCorner = CornerRadius(radius) + val innerCorner = CornerRadius(radius - border / 2) + onDrawBehind { + val layout = list.layoutInfo + val visible = layout.visibleItemsInfo + val selected = capsuleIndex + var index = 0 + while (index < visible.size) { + val chip = visible[index++] + if (chip.index != selected) continue + val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f) + val bounds = Size(capsuleWidth.value, height) + drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner) + drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke) + break + } + } + }, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { val seasonNumbers = state.data.seasonNumbers val keys = seasonNumbers.map { "season-$it" }.uniqueKeys() items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index -> @@ -211,12 +286,12 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { val focused = active && state.column == index val selected = state.season == number val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) + val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) - .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) + .background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape) .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) + Text("Season $number", color = label, style = WolfskinTokens.Type.label) } } } @@ -238,4 +313,3 @@ private fun TitleReadError(state: TitleState) { } } -internal val titleFrameScrollSpec get() = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 70157f2e..d7cdba74 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -20,6 +20,11 @@ class TitleState( private val centers = HashMap>() fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x } fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) } + /** Cached episodes replace the loading state on the season-selection frame. */ + fun showSeason(number: Int, episodes: List) { + season = number + data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready) + } fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) } var sheet by mutableStateOf(null) private set @@ -37,6 +42,7 @@ class TitleState( var subtitleIndex by mutableStateOf(initial.selectedSubtitle) var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) + val playbackStart = PlaybackStart() private var armedCard: String? = null private var seasonChosen = false val requestedSeason: Int? get() = season.takeIf { seasonChosen } @@ -115,6 +121,7 @@ class TitleState( /** Key-up can only activate the same card that armed the first key-down. */ fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { + if (playbackStart.launch != null) return true if (key == ShellKey.Ok) { if (!down) { okHeld = false @@ -147,6 +154,8 @@ class TitleState( /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ fun back(shell: ShellState) { + if (playbackStart.launch != null) return + playbackStart.fail() when { sheet != null -> closeSheet() shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) @@ -187,7 +196,7 @@ class TitleState( private fun activateOption(id: String) { when (id) { - "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu } -- 2.49.1 From 7cd38026f1cb983e9fc3a2a15287e2fef1ce44e3 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 04:03:44 +0100 Subject: [PATCH 10/17] Establish painted navigation and player-return preconditions --- .../wolfskin/tv/HomeHeroStartingTest.kt | 9 ++- .../wolfskin/tv/NavigationCutTest.kt | 55 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt index 8f6868d7..9fffeed4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -130,9 +130,14 @@ class HomeHeroStartingTest { assertNull(session.playback.starting) assertNull(session.playback.launch) coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) } + // Returning from the player requires a painted handoff first, not a pop in the same unrendered turn. + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "home-disposed.png").absolutePath) + rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0) File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n")) - rule.runOnIdle { navigation.goBack() } - rule.mainClock.advanceTimeBy(100) + rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath) rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() rule.onNodeWithText("Starting…").assertDoesNotExist() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index ce6d3a1e..5c62575e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -35,7 +35,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Every observed native frame has one opaque screen; the incoming scene must compose within 16 ms. */ +/** Shell cuts retire the old screen within 16 ms; setup paints an opaque replacement within 32 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -50,13 +50,25 @@ class NavigationCutTest { private fun cuts(reduced: Boolean) { host = PolishAppHost(rule) host.mount(picker = false, reduced = reduced) - rule.mainClock.autoAdvance = false val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() } val samples = mutableListOf() val homeCard = SemanticsMatcher("Home card") { it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) && it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:") } + // Establish a painted Home and a focused shelf card before starting the navigation clock. + rule.onRoot().captureRoboImage(File(directory, "home-precondition.png").absolutePath) + rule.onNodeWithText("Who’s watching?").assertDoesNotExist() + rule.runOnIdle { host.session.state!!.focusNav(0); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + repeat(2) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L) } + rule.waitForIdle() + } + assertFalse("Home card activation cannot start from a picker", host.session.state!!.pickerVisible) + assertNotNull("The OK precondition is a shelf card, not the nav or hero", host.session.state!!.contentFocus?.cardKey) + assertTrue(host.navigation.backStack.single() is Destination.Home) + rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath) + rule.mainClock.autoAdvance = false fun strip(phase: String) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } val origin = rule.mainClock.currentTime @@ -100,7 +112,8 @@ class NavigationCutTest { File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n")) } } - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size) strip("open-title") rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } strip("back") @@ -139,8 +152,8 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } - @Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false) - @Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true) + @Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false) + @Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = setupCuts(true) private fun setupCuts(reduced: Boolean) { val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) @@ -161,9 +174,11 @@ class NavigationCutTest { true }) } } } - rule.mainClock.autoAdvance = false val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } val samples = mutableListOf() + rule.onRoot().captureRoboImage(File(directory, "setup-precondition.png").absolutePath) + rule.onAllNodesWithText("ServerList").assertCountEquals(1) + rule.mainClock.autoAdvance = false for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { val previous = stack.single().javaClass.simpleName val next = destination.javaClass.simpleName @@ -178,19 +193,27 @@ class NavigationCutTest { rule.waitForIdle() val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size } val image = BitmapFactory.decodeFile(file.absolutePath) - val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } + val pixel = try { + val sample = image.getPixel(600, 500) + assertEquals("The entire setup scene must share its opaque paint", sample, image.getPixel(100, 500)) + assertEquals(sample, image.getPixel(1500, 500)) + assertEquals(sample, image.getPixel(600, 900)) + sample + } finally { image.recycle() } samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel") assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) - assertEquals("One setup screen per observed frame, never both or blank", 1, counts.values.sum()) - val incoming = counts.getValue(next) == 1 - assertEquals("Setup screens are opaque, not intermediate-alpha composites", colors.getValue(if (incoming) next else previous), pixel) + val incoming = pixel == colors.getValue(next) + assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite", + incoming || pixel == colors.getValue(previous)) if (incoming && firstIncoming == null) { firstIncoming = index - assertTrue("$next first composed after $index frames (${index * 16} ms), not within one frame", index <= 1) + assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2) } - if (firstIncoming != null) { - assertTrue("The incoming setup screen cannot disappear", incoming) + if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming) + if (index >= 2) { + assertEquals("By 32 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) rule.onNodeWithText(previous).assertDoesNotExist() + rule.onAllNodesWithText(next).assertCountEquals(1) } } assertNotNull("Setup screen did not appear within 320 ms", firstIncoming) @@ -267,7 +290,11 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule Date: Sun, 8 Nov 2026 04:31:20 +0100 Subject: [PATCH 11/17] Measure native cuts independently of retained semantics --- .../wolfskin/tv/NavigationCutTest.kt | 71 ++++++++++++------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index 5c62575e..443a7258 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -35,7 +35,7 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Shell cuts retire the old screen within 16 ms; setup paints an opaque replacement within 32 ms. */ +/** Native cuts paint by 32 ms; outgoing semantics retire by 64 ms without any whole-screen alpha. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -44,8 +44,8 @@ class NavigationCutTest { private lateinit var host: PolishAppHost @After fun dispose() { if (::host.isInitialized) host.close() } - @Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false) - @Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true) + @Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false) + @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true) private fun cuts(reduced: Boolean) { host = PolishAppHost(rule) @@ -69,11 +69,20 @@ class NavigationCutTest { assertTrue(host.navigation.backStack.single() is Destination.Home) rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath) rule.mainClock.autoAdvance = false - fun strip(phase: String) { + fun strip(phase: String, previousFrame: File) { rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } val origin = rule.mainClock.currentTime var firstIncoming: Int? = null - var opaqueAvatar = 0 + // These fixture-art probes are outside the entering title copy. Composition alone cannot prove paint. + val x = intArrayOf(1500, 1700, 1900, 1500, 1700, 1900) + val y = intArrayOf(300, 500, 700, 700, 900, 300) + val outgoingPaint = IntArray(x.size) + val paint = IntArray(x.size) + val previous = BitmapFactory.decodeFile(previousFrame.absolutePath) + val opaqueAvatar = try { + for (point in x.indices) outgoingPaint[point] = previous.getPixel(x[point], y[point]) + previous.getPixel(1774, 90) + } finally { previous.recycle() } try { repeat(21) { index -> if (index > 0) rule.mainClock.advanceTimeByFrame() @@ -85,40 +94,50 @@ class NavigationCutTest { val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size val image = BitmapFactory.decodeFile(file.absolutePath) - val avatar = try { image.getPixel(1774, 90) } finally { image.recycle() } - samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar") + val avatar = try { + for (point in x.indices) paint[point] = image.getPixel(x[point], y[point]) + image.getPixel(1774, 90) + } finally { image.recycle() } + samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar,${paint.joinToString(";")}") assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime) - assertEquals("Exactly one destination owns the nav bar in every frame", 1, shells) - assertEquals("No overlap or blank destination during the cut", 1, titles + libraries + if (cards > 0) 1 else 0) - if (index == 0) opaqueAvatar = avatar - assertEquals("The persistent avatar cannot acquire whole-screen transition alpha", opaqueAvatar, avatar) - val incoming = when (phase) { + assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar) + val composed = when (phase) { "open-title" -> titles == 1 "back" -> cards > 0 else -> libraries == 1 } + val incoming = composed && !paint.contentEquals(outgoingPaint) if (incoming && firstIncoming == null) { firstIncoming = index - assertTrue("$phase first composed incoming after $index frames (${index * 16} ms), not within one frame", index <= 1) + assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), not within two frames", index <= 2) } - if (firstIncoming != null) { - assertTrue("The incoming destination cannot disappear again", incoming) - if (phase != "back") assertEquals("No outgoing Home card survives the first incoming frame", 0, cards) - else assertEquals("Back cannot retain the title page", 0, titles) + if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming) + if (index >= 4) { + assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells) + assertEquals(if (phase == "open-title") 1 else 0, titles) + assertEquals(if (phase == "tab") 1 else 0, libraries) + if (phase != "back") assertEquals("Outgoing Home cards must retire by 64 ms", 0, cards) + else assertTrue("Back must restore Home, not leave a blank destination", cards > 0) } } assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming) } finally { - File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel\n" + samples.joinToString("\n")) + File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel,backdropPixels\n" + samples.joinToString("\n")) } } rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size) - strip("open-title") + strip("open-title", File(directory, "home-card-ready.png")) rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } - strip("back") - rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } - strip("tab") + strip("back", File(directory, "open-title-20.png")) + // Settle focus before OK; that focus animation is not a screen transition. + rule.mainClock.autoAdvance = true + rule.runOnIdle { host.session.state!!.focusNav(2); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.onRoot().captureRoboImage(File(directory, "tab-ready.png").absolutePath) + assertTrue(host.navigation.backStack.single() is Destination.Home) + rule.mainClock.autoAdvance = false + rule.runOnIdle { host.session.state!!.key(ShellKey.Ok) } + strip("tab", File(directory, "tab-ready.png")) } @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { @@ -152,8 +171,8 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } - @Test fun setupHostPaintsOneOpaqueScreenAndRetiresOutgoingByTwoFrames() = setupCuts(false) - @Test fun reducedMotionSetupHostKeepsTheSameTwoFrameLimit() = setupCuts(true) + @Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false) + @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true) private fun setupCuts(reduced: Boolean) { val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) @@ -210,8 +229,8 @@ class NavigationCutTest { assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2) } if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming) - if (index >= 2) { - assertEquals("By 32 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) + if (index >= 4) { + assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum()) rule.onNodeWithText(previous).assertDoesNotExist() rule.onAllNodesWithText(next).assertCountEquals(1) } -- 2.49.1 From bfa9fbc941c43336b7eaa8959a7b9ea88af23980 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 05:33:54 +0100 Subject: [PATCH 12/17] Keep playback black through native scene handoff --- .../wolfskin/tv/NavigationCutTest.kt | 86 ++++++++++++++++++- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 32 +++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index 443a7258..a8885d7f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -35,7 +36,9 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Native cuts paint by 32 ms; outgoing semantics retire by 64 ms without any whole-screen alpha. */ +private const val PLAYER_PAINT = 0xff204060 + +/** Native forward cuts paint by 32 ms, Back by 64 ms; all outgoing semantics retire by 64 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -44,7 +47,7 @@ class NavigationCutTest { private lateinit var host: PolishAppHost @After fun dispose() { if (::host.isInitialized) host.close() } - @Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false) + @Test fun titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false) @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true) private fun cuts(reduced: Boolean) { @@ -109,7 +112,8 @@ class NavigationCutTest { val incoming = composed && !paint.contentEquals(outgoingPaint) if (incoming && firstIncoming == null) { firstIncoming = index - assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), not within two frames", index <= 2) + val limit = if (phase == "back") 4 else 2 + assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), exceeding $limit frames", index <= limit) } if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming) if (index >= 4) { @@ -171,6 +175,72 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } + @Test fun fullRootPlaybackHandoffNeverRepaintsHome() = rootHandoff(false) + @Test fun reducedMotionFullRootHandoffNeverRepaintsHome() = rootHandoff(true) + + private fun rootHandoff(reduced: Boolean) { + host = PolishAppHost(rule) + host.playerReady = false + host.mount(picker = false, reduced = reduced) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/root-" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) + rule.mainClock.autoAdvance = false + val destination = Destination.Playback(UUID.randomUUID(), 0L) + rule.runOnIdle { + host.session.playback.begin("hero-play") + host.session.playback.ready { host.navigation.navigateTo(destination) } + } + val origin = rule.mainClock.currentTime + val samples = mutableListOf() + var navigationTime: Long? = null + var playerPainted = false + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, "handoff-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + val logicalPlayer = host.navigation.backStack.last() == destination + val time = rule.mainClock.currentTime - origin + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + val pixel = image.getPixel(600, 500) + val avatar = image.getPixel(1774, 90) + samples.add("$time,$logicalPlayer,$pixel,$avatar") + if (logicalPlayer) { + if (navigationTime == null) navigationTime = time + assertTrue("After handoff only opaque black or the incoming player may paint; never old Home", + pixel == android.graphics.Color.BLACK || pixel == PLAYER_PAINT.toInt()) + assertEquals("The old Home nav cannot flash through the completed veil", pixel, avatar) + assertEquals(pixel, image.getPixel(100, 900)) + assertEquals(pixel, image.getPixel(1800, 900)) + if (pixel == PLAYER_PAINT.toInt()) playerPainted = true + if (pixel == android.graphics.Color.BLACK && time == navigationTime + 16) { + val revision = host.session.state!!.inputRevision + rule.onRoot().performKeyInput { + pressKey(Key.DirectionRight, 0L); pressKey(Key.DirectionCenter, 0L); pressKey(Key.Escape, 0L) + } + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertEquals("Keys cannot reach old Home during the pending native player scene", revision, host.session.state!!.inputRevision) + assertEquals(destination, host.navigation.backStack.last()) + } + if (time == navigationTime + 48) rule.runOnIdle { + host.playerReady = true + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + } + } + } finally { image.recycle() } + assertEquals("The full-root probe cannot warm the manual clock", origin + index * 16L, rule.mainClock.currentTime) + } + assertTrue("The incoming player must eventually be uncovered", playerPainted) + assertEquals(2, host.navigation.backStack.size) + rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0) + assertNull(host.session.playback.starting) + assertNull(host.session.playback.launch) + } finally { + File(directory, "samples.csv").writeText("timeMs,logicalPlayer,bodyPixel,avatarPixel\n" + samples.joinToString("\n")) + } + } + @Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false) @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true) @@ -260,6 +330,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule(relaxed = true) + every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE) coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) } coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } every { setup.navigateTo(any()) } answers { @@ -281,7 +354,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule + assertTrue(destination is Destination.Playback) + androidx.compose.foundation.layout.Box(modifier.then(if (playerReady) Modifier.background(androidx.compose.ui.graphics.Color(PLAYER_PAINT)) else Modifier) + .testTag("polish-player")) }) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 9ba1c4ee..20990590 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -5,17 +5,21 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -293,6 +297,8 @@ fun WolfskinApp( { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = { route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, + fullScreenContent: @Composable (Destination, Modifier) -> Unit = + { destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() @@ -330,12 +336,21 @@ fun WolfskinApp( commit.fallback?.invoke() } } + var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf(null) } + val playerEntryPending by remember(server.id, user.id, navigationManager) { + derivedStateOf { + val target = navigationManager.backStack.lastOrNull() + target is Destination.Playback && composedDestination != target + } + } + // Keep input blocked until the composition that removes the black cover has actually replaced it. + val playerCoverVisible = playerEntryPending WolfskinTheme { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback, org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { Box(modifier.onPreviewKeyEvent { event -> - if (session.personCommit != null || session.playback.launch != null) true else { + if (session.personCommit != null || session.playback.launch != null || playerEntryPending || playerCoverVisible) true else { if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail() false } @@ -352,7 +367,7 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - onBack = { if (session.personCommit == null && session.playback.launch == null) { session.playback.fail(); onBack() } }, + onBack = { if (session.personCommit == null && session.playback.launch == null && !playerEntryPending && !playerCoverVisible) { session.playback.fail(); onBack() } }, transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, @@ -367,12 +382,15 @@ fun WolfskinApp( val discover = LocalDiscoverState.current val route = destination.toWolfskinRoute() val isCurrent = navigationManager.backStack.lastOrNull() == destination + if (isCurrent) SideEffect { composedDestination = destination } LaunchedEffect(destination, state, isCurrent) { if (isCurrent && !destination.fullScreen) state.enterDestination(route) } org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) { if (destination.fullScreen) { - DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) + // Loading, error and external-player content may not paint their own background. + fullScreenContent(destination, Modifier.fillMaxSize() + .then(if (destination is Destination.Playback) Modifier.background(Color.Black) else Modifier)) } else { val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { @@ -410,12 +428,16 @@ fun WolfskinApp( Modifier.pointerInput(session.playback.launch) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) + if (playerCoverVisible) Box(Modifier.fillMaxSize().background(Color.Black) + .pointerInput(Unit) { + awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } + }) if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit) - BackHandler(enabled = session.playback.starting != null && session.playback.launch == null) { + BackHandler(enabled = session.playback.starting != null && session.playback.launch == null && !playerCoverVisible) { session.playback.fail() onBack() } - BackHandler(enabled = commit != null || session.playback.launch != null) { } + BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { } } } } -- 2.49.1 From 1bdae30e11cff6195f8d4379333d45eb2bd10eee Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 06:46:24 +0100 Subject: [PATCH 13/17] Restore bounded trailer tasks after process death --- .../damontecres/wholphin/MainActivity.kt | 206 +--------- .../wholphin/MainActivityViewModel.kt | 254 ++++++++++++ .../wholphin/data/ServerRepository.kt | 32 +- .../wholphin/MainActivityResumeTest.kt | 2 +- .../MainActivityTrailerRestoreTest.kt | 376 ++++++++++++++++++ .../wholphin/SignedInContentTest.kt | 2 +- .../wholphin/TestMainActivityViewModel.kt | 18 +- .../wolfskin/tv/AnimeAvailabilityTest.kt | 2 +- .../wolfskin/tv/AnimeRootRemovalTest.kt | 2 +- .../tv/ConductarrAudienceRecoveryTest.kt | 4 +- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 2 +- .../wolfskin/tv/DiscoverRefreshMotionTest.kt | 6 +- .../wolfskin/tv/DiscoverTitleTimingTest.kt | 2 +- .../wolfskin/tv/FirstSignInNavigationTest.kt | 2 +- .../wolfskin/tv/PersonPickerSessionTest.kt | 2 +- .../wolfskin/tv/PersonSwitchThreadingTest.kt | 6 +- .../wolfskin/tv/SavedPeoplePickerTest.kt | 2 +- .../wolfskin/tv/ShellStartupTimingTest.kt | 4 +- .../wolfskin/tv/TitleAsyncFocusTest.kt | 6 +- .../wolfskin/tv/TitleEntryBackTest.kt | 4 +- .../tv/TitlePlaybackNavigationTest.kt | 4 +- .../wolfskin/tv/TitleRequestSafetyTest.kt | 2 +- .../wolfskin/tv/TitleThemeMusicTest.kt | 2 +- .../wolfskin/tv/TitleTrailerTest.kt | 6 +- .../wolfskin/tv/TrailerReturnStateTest.kt | 150 +++++++ .../wolfskin/tv/WolfskinRootExitTest.kt | 2 +- .../tv/WolfskinSetupNavigationTest.kt | 18 +- .../wolfskin/tv/TitleDestination.kt | 9 +- .../justinvisser/wolfskin/tv/TitleTrailer.kt | 3 +- .../wolfskin/tv/TrailerReturnState.kt | 145 +++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 13 +- docker/README.md | 41 +- 32 files changed, 1082 insertions(+), 247 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 15524556..66a2b040 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin -import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle @@ -9,7 +8,6 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -24,24 +22,16 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.datastore.core.DataStore -import androidx.lifecycle.ViewModel import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import androidx.navigation3.runtime.NavBackStack import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface -import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.RestoredSession -import org.justinvisser.wolfskin.tv.recoverWolfskinSession import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService -import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService -import com.github.damontecres.wholphin.services.IntentResult import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager @@ -66,7 +56,6 @@ import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel -import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree @@ -74,20 +63,17 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.requestSerializersModule import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import timber.log.Timber +import org.justinvisser.wolfskin.tv.TrailerReturnState import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -156,6 +142,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var intentService: IntentService + + @Inject + lateinit var trailerReturn: TrailerReturnState private var signInAuto = true private var playerBackend: PlayerBackend? = null @@ -169,6 +158,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) instance = this + if (savedInstanceState == null) trailerReturn.clear() Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) @@ -233,7 +223,7 @@ class MainActivity : AppCompatActivity() { playerBackend = prefs.playbackPreferences.playerBackend } - viewModel.appStart(intent, resuming = savedInstanceState != null) + viewModel.appStart(intent, resuming = savedInstanceState != null, savedState = savedInstanceState) setContent { MaterialTheme { Surface(Modifier.fillMaxSize()) { @@ -310,6 +300,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() Timber.d("onResume") + trailerReturn.finishHandoff() viewModel.appResume() lifecycleScope.launchDefault { screensaverService.pulse() @@ -319,6 +310,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") + trailerReturn.clear() viewModel.appStart(null, resuming = true) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page @@ -375,6 +367,7 @@ class MainActivity : AppCompatActivity() { Timber.w(ex, "Could not persist back stack; skipping") } outState.putBoolean(KEY_EXTERNAL_PLAYER, playerBackend == PlayerBackend.EXTERNAL_PLAYER) + trailerReturn.save(outState, viewModel.serverRepository.current.value, navigationManager.backStack.lastOrNull()) } override fun onRestoreInstanceState(savedInstanceState: Bundle) { @@ -425,187 +418,6 @@ class MainActivity : AppCompatActivity() { } } -@HiltViewModel -class MainActivityViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val preferences: DataStore, - val serverRepository: ServerRepository, - private val setupNavigationManager: SetupNavigationManager, - private val navigationManager: NavigationManager, - private val deviceProfileService: DeviceProfileService, - private val backdropService: BackdropService, - private val appUpgradeHandler: AppUpgradeHandler, - private val intentService: IntentService, - ) : ViewModel() { - private val mutex = Mutex() - - fun appStart(intent: Intent?, resuming: Boolean = false) { - viewModelScope.launchDefault { - mutex.withLock { - try { - handleAppUpgrade() - } catch (ex: Exception) { - Timber.e(ex, "Error during app upgrade!") - showToast( - context, - "Error during app upgrade: ${ex.localizedMessage}", - ) - } - - try { - val result = handleIntent(intent) - if (result) { - Timber.i("appStart was handled by intent") - return@withLock - } - } catch (ex: Exception) { - Timber.e(ex, "Error parsing intent") - } - - try { - handleAppStart(resuming) - } catch (ex: Exception) { - Timber.e(ex, "Error during appStart") - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } - } - viewModelScope.launchDefault { - // Create the mediaCodecCapabilitiesTest if needed - deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() - } - } - - fun appResume() { - viewModelScope.launchDefault { - mutex.withLock { - val expected = serverRepository.current.value - try { - Timber.v("Updating userDto") - serverRepository.updateUserDto() - } catch (ex: Exception) { - Timber.w(ex, "Error during appResume") - recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager) - } - } - } - } - - /** - * Handle an [Intent], navigating if needed - * - * @return true if the intent was handled, false if normal app start should proceed - */ - @VisibleForTesting - internal suspend fun handleIntent(intent: Intent?): Boolean { - val result = intent?.let { intentService.parseIntent(intent) } - when (result) { - is IntentResult.Error -> { - val current = serverRepository.current.value - val destination = - if (current != null) { - SetupDestination.UserList(current.server) - } else { - SetupDestination.ServerList - } - setupNavigationManager.navigateTo(destination) - Timber.e("Error parsing intent: %s", result.message) - showToast(context, result.message) - return true - } - - is IntentResult.Target -> { - val current = serverRepository.current.value - if (current != null) { - Timber.i("Received valid intent, switching to AppContent") - - if (result.addHomeToBackStack) { - navigationManager.reloadHome() - } else { - navigationManager.backStack.clear() - } - navigationManager.backStack.addAll(result.destinations) - - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } else { - // This should never happen, but just reset app state if it does - setupNavigationManager.navigateTo(SetupDestination.ServerList) - Timber.e("Error parsing intent, no user is active") - showToast(context, "An error occurred parsing the intent") - } - return true - } - - IntentResult.NoOp, - null, - -> { - // Not handled - return false - } - } - } - - private suspend fun handleAppUpgrade() { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() - } - appUpgradeHandler.copySubfont(false) - } - - /** - * Handle starting the app including auto sign in and navigating to the right starting page - */ - @VisibleForTesting - internal suspend fun handleAppStart(resuming: Boolean = false) { - val signInAutomatically = preferences.data.first().signInAutomatically - val currentUser = serverRepository.current.value - // A picker may be an explicit switch or expired login, even while a user remains cached. - val shown = if (resuming && !signInAutomatically) { - (setupNavigationManager.backStack.lastOrNull() as? SetupDestination.AppContent)?.current - } else null - val canContinue = currentUser != null && shown != null && - currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id - if ((signInAutomatically || canContinue) && currentUser != null && !currentUser.user.isProtected) { - Timber.v("Hot reload restoring state") - setupNavigationManager.navigateTo( - SetupDestination.AppContent(currentUser), - ) - } else { - val restoredSession = - if (signInAutomatically) { - Timber.v("Sign in automatically") - serverRepository.restoreSavedSession() - } else { - serverRepository.getMostRecentServer() - } - Timber.d("Restore last session result=%s", restoredSession) - setupNavigationManager.navigateTo(restoredSession.destination) - if (signInAutomatically && restoredSession is RestoredSession.Success) { - viewModelScope.launchDefault { - try { - serverRepository.revalidateSession(restoredSession.currentUser) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - Timber.w(exception, "Saved launch session revalidation failed") - recoverWolfskinSession(context, exception, restoredSession.currentUser, serverRepository, setupNavigationManager) - } - } - } - } - } - } private val Destination?.isPlayback: Boolean get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt new file mode 100644 index 00000000..271e47b6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt @@ -0,0 +1,254 @@ +package com.github.damontecres.wholphin + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.annotation.VisibleForTesting +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.RestoredSession +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.AppUpgradeHandler +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.IntentResult +import com.github.damontecres.wholphin.services.IntentService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.tv.TrailerReturnState +import org.justinvisser.wolfskin.tv.recoverWolfskinSession +import timber.log.Timber + +@HiltViewModel +class MainActivityViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val preferences: DataStore, + val serverRepository: ServerRepository, + private val setupNavigationManager: SetupNavigationManager, + private val navigationManager: NavigationManager, + private val deviceProfileService: DeviceProfileService, + private val backdropService: BackdropService, + private val appUpgradeHandler: AppUpgradeHandler, + private val intentService: IntentService, + private val trailerReturn: TrailerReturnState, + ) : ViewModel() { + private val mutex = Mutex() + private var startupJob: kotlinx.coroutines.Job? = null + + fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) { + if (!resuming) trailerReturn.clear() + startupJob?.cancel() + startupJob = viewModelScope.launchDefault { + mutex.withLock { + try { + handleAppUpgrade() + } catch (ex: Exception) { + if (ex is kotlinx.coroutines.CancellationException) throw ex + Timber.e(ex, "Error during app upgrade!") + showToast( + context, + "Error during app upgrade: ${ex.localizedMessage}", + ) + } + + try { + // A saved trailer task must not replay its original deep link or authenticate its old intent's person. + val restoringTrailer = resuming && savedState?.containsKey(TrailerReturnState.SAVED_STATE_KEY) == true + val result = if (restoringTrailer) false else handleIntent(intent) + if (result) { + Timber.i("appStart was handled by intent") + return@withLock + } + } catch (ex: Exception) { + if (ex is kotlinx.coroutines.CancellationException) throw ex + Timber.e(ex, "Error parsing intent") + } + + try { + handleAppStart(resuming, savedState) + } catch (ex: Exception) { + if (ex is kotlinx.coroutines.CancellationException) throw ex + Timber.e(ex, "Error during appStart") + setupNavigationManager.navigateTo(SetupDestination.ServerList) + } + } + } + viewModelScope.launchDefault { + // Create the mediaCodecCapabilitiesTest if needed + deviceProfileService.mediaCodecCapabilitiesTest.supportsAVC() + } + } + + fun appResume() { + viewModelScope.launchDefault { + mutex.withLock { + val expected = serverRepository.current.value + try { + Timber.v("Updating userDto") + serverRepository.updateUserDto() + } catch (ex: Exception) { + Timber.w(ex, "Error during appResume") + recoverWolfskinSession(context, ex, expected, serverRepository, setupNavigationManager) + } + } + } + } + + /** + * Handle an [Intent], navigating if needed + * + * @return true if the intent was handled, false if normal app start should proceed + */ + @VisibleForTesting + internal suspend fun handleIntent(intent: Intent?): Boolean { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, result.message) + return true + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return true + } + + IntentResult.NoOp, + null, + -> { + // Not handled + return false + } + } + } + + private suspend fun handleAppUpgrade() { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), + ) + appUpgradeHandler.run() + } + appUpgradeHandler.copySubfont(false) + } + + /** + * Handle starting the app including auto sign in and navigating to the right starting page + */ + @VisibleForTesting + internal suspend fun handleAppStart(resuming: Boolean = false, savedState: Bundle? = null) { + val signInAutomatically = preferences.data.first().signInAutomatically + val currentUser = serverRepository.current.value + val owner = setupNavigationManager.backStack.lastOrNull() + val expected = if (resuming && savedState != null) { + trailerReturn.restore(savedState, navigationManager.backStack.lastOrNull()) + } else { + trailerReturn.clear() + null + } + val shown = (owner as? SetupDestination.AppContent)?.current + val canContinue = currentUser != null && shown != null && + currentUser.server.id == shown.server.id && currentUser.user.id == shown.user.id + if (expected != null) { + val restored = when { + currentUser == null && owner == SetupDestination.Loading -> + serverRepository.restoreSavedSession(expected) { + trailerReturn.isRestoring(expected) && serverRepository.current.value == null && + setupNavigationManager.backStack.lastOrNull() === owner + } + currentUser != null && (canContinue || owner == SetupDestination.Loading) && !currentUser.user.isProtected && + currentUser.server.id == expected.serverId && currentUser.user.id == expected.userId && + serverRepository.isSavedIdentity(expected) -> RestoredSession.Success(currentUser) + else -> serverRepository.getMostRecentServer() + } + finishRestore(restored, owner, fromTrailer = true, revalidate = currentUser == null) + return + } + // A picker may be an explicit switch or expired login, even while a user remains cached. + if ((signInAutomatically || (resuming && canContinue)) && currentUser != null && !currentUser.user.isProtected) { + withContext(WholphinDispatchers.Main.immediate) { + if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) { + setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) + } + } + } else { + val restored = if (signInAutomatically) serverRepository.restoreSavedSession() else serverRepository.getMostRecentServer() + finishRestore(restored, owner, fromTrailer = false, revalidate = true) + } + } + + private suspend fun finishRestore( + restored: RestoredSession, + owner: SetupDestination?, + fromTrailer: Boolean, + revalidate: Boolean, + ) { + val shown = withContext(WholphinDispatchers.Main.immediate) { + if (setupNavigationManager.backStack.lastOrNull() !== owner) return@withContext false + if (restored is RestoredSession.Success && serverRepository.current.value !== restored.currentUser) return@withContext false + if (fromTrailer && restored is RestoredSession.Success) trailerReturn.confirmRestore(restored.currentUser) + else trailerReturn.clear() + setupNavigationManager.navigateTo(restored.destination) + true + } + if (shown && revalidate && restored is RestoredSession.Success) { + viewModelScope.launchDefault { + try { + serverRepository.revalidateSession(restored.currentUser) + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + Timber.w(exception, "Saved launch session revalidation failed") + recoverWolfskinSession(context, exception, restored.currentUser, serverRepository, setupNavigationManager) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 0930129e..814b5287 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -120,12 +122,35 @@ class ServerRepository } /** Restore only an unprotected saved login; the launch picker must not wait for the network. */ - suspend fun restoreSavedSession(): RestoredSession = withContext(ioDispatcher) { + suspend fun restoreSavedSession(): RestoredSession = restoreSavedSessionInternal(null, null) + + /** A task-bound restore cannot select another saved person or outlive its caller's authorization. */ + suspend fun restoreSavedSession( + expected: SavedSessionIdentity, + isStillAuthorized: () -> Boolean, + ): RestoredSession = restoreSavedSessionInternal(expected, isStillAuthorized) + + /** Clearing/changing the saved login also invalidates a surviving trailer activity's handoff. */ + fun isSavedIdentity(expected: SavedSessionIdentity): Boolean = + matchesSavedIdentity(mostRecentServerProvider.get(), expected) + + private fun matchesSavedIdentity(recent: MostRecentServer, expected: SavedSessionIdentity): Boolean = + recent is MostRecentServer.ServerAndUser && recent.serverId == expected.serverId && recent.userId == expected.userId + + private suspend fun restoreSavedSessionInternal( + expected: SavedSessionIdentity?, + isStillAuthorized: (() -> Boolean)?, + ): RestoredSession = withContext(ioDispatcher) { val recent = mostRecentServerProvider.get() + if (expected != null && !matchesSavedIdentity(recent, expected)) return@withContext getMostRecentServerInternal(recent) if (recent !is MostRecentServer.ServerAndUser) return@withContext getMostRecentServerInternal(recent) val saved = serverDao.getServer(recent.serverId) ?: return@withContext RestoredSession.None val user = saved.users.firstOrNull { it.id == recent.userId } - if (user == null || user.isProtected) return@withContext RestoredSession.ServerOnly(saved.server) + if (user == null || user.isProtected || (expected != null && user.accessToken.isNullOrBlank())) { + return@withContext RestoredSession.ServerOnly(saved.server) + } + currentCoroutineContext().ensureActive() + if (isStillAuthorized?.invoke() == false) return@withContext RestoredSession.ServerOnly(saved.server) apiClient.update(baseUrl = saved.server.url, accessToken = user.accessToken) val current = CurrentUser(saved.server, user) _currentUserDto.value = null @@ -384,3 +409,6 @@ data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, ) + +/** A previously authenticated server/person pair, without credentials or protection state. */ +data class SavedSessionIdentity(val serverId: UUID, val userId: UUID) diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt index c7b0fce0..ec5143c0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -39,7 +39,7 @@ class MainActivityResumeTest { JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")) private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation, - mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) @Before fun configure() { WholphinDispatchers.configure(dispatcher) diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt new file mode 100644 index 00000000..fcf8d8e4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityTrailerRestoreTest.kt @@ -0,0 +1,376 @@ +package com.github.damontecres.wholphin + +import android.app.Application +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ResolveInfo +import android.net.Uri +import android.os.Bundle +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.datastore.core.DataStore +import androidx.lifecycle.viewModelScope +import androidx.room.Room +import com.github.damontecres.wholphin.data.* +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.update +import com.github.damontecres.wholphin.services.* +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.successResponse +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.util.UUID +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.operations.SystemApi +import org.jellyfin.sdk.api.operations.UserApi +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.jellyfin.sdk.model.api.UserDto +import org.json.JSONObject +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.justinvisser.wolfskin.tv.update.FakeUpdateController +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** New repository and shell instances reproduce process death, not merely activity recreation. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class MainActivityTrailerRestoreTest { + @get:Rule val rule = createAndroidComposeRule() + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", "12.1.0") + private var user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "saved-token") + private val users = mockk() + private val system = mockk() + private val api = mockk(relaxed = true) + private val recent = mockk() + private var recentValue: MostRecentServer = MostRecentServer.None + private val preferences = mockk>() + private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) + private var elapsed = 1_000L + private val clock = mockk().also { + every { it.readElapsedMillis() } answers { elapsed } + every { it.readBootCount() } returns 7 + } + private val restoredReturn = TrailerReturnState(clock) + private val navigation = NavigationManager() + private val setup = mockk(relaxed = true) + private val title = Destination.Wolfskin(titleRoute("Locke")) + private val database by lazy { + Room.inMemoryDatabaseBuilder(rule.activity, AppDatabase::class.java).allowMainThreadQueries().build() + } + private val dao by lazy { spyk(database.serverDao()) } + private val repository by lazy { ServerRepository(rule.activity, mockk(), dao, api, Dispatchers.Unconfined, recent) } + private val intents = mockk(relaxed = true) + private val model by lazy { MainActivityViewModel(rule.activity, preferences, repository, setup, navigation, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intents, restoredReturn) } + @Volatile private var destination: SetupDestination = SetupDestination.Loading + private val conductarr = ConductarrSession(MemoryCacheStore()) + private var shellModel: WolfskinShellViewModel? = null + private var previousFixtures = false + private var previousScreens = true + + @Before fun savedIdentityButNoLiveSession() { + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + previousFixtures = ConductarrPreference.fixtures + previousScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(true) + WolfskinScreenPreference.enabled = true + dao.addOrUpdateServer(server) + user = dao.addOrUpdateUser(user) + recentValue = MostRecentServer.from(CurrentUser(server, user)) + every { recent.get() } answers { recentValue } + every { api.userApi } returns users + every { api.systemApi } returns system + coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = user.id, name = "Justin", + hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false)) + coEvery { system.getPublicSystemInfo() } returns successResponse(PublicSystemInfo(serverName = server.name, version = server.version)) + every { preferences.data } returns automatic + every { setup.backStack } answers { mutableListOf(destination) } + every { setup.navigateTo(any()) } answers { destination = firstArg() } + navigation.backStack.add(title) + val handler = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { packageName = "test.youtube"; name = "test.youtube.Player"; exported = true } + } + shadowOf(rule.activity.packageManager).addResolveInfoForIntent( + Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), handler) + assertNull(repository.current.value) + } + + @After fun closeSessions() { + val jobs = listOfNotNull(model.viewModelScope.coroutineContext[Job], shellModel?.viewModelScope?.coroutineContext?.get(Job)) + jobs.forEach { it.cancel() } + shadowOf(Looper.getMainLooper()).idle() + runBlocking { jobs.forEach { it.join() } } + conductarr.close() + database.close() + ConductarrPreference.updateFixtures(previousFixtures) + WolfskinScreenPreference.enabled = previousScreens + } + + private fun snapshot(): Bundle { + val oldProcess = TrailerReturnState(clock) + val alreadySignedIn = CurrentUser(server, user) + check(TitleTrailer(rule.activity).open("https://www.youtube.com/watch?v=ab_-defghij") { + oldProcess.prepare(alreadySignedIn, title) + }) + return Bundle().also { oldProcess.save(it, alreadySignedIn, title) } + } + + private fun restore(saved: Bundle? = snapshot(), resuming: Boolean = true) { + val result = model.viewModelScope.async(Dispatchers.Default) { model.handleAppStart(resuming = resuming, savedState = saved) } + rule.waitUntil(15_000) { + shadowOf(Looper.getMainLooper()).idle() + result.isCompleted + } + runBlocking { result.await() } + } + + private fun assertGated() { + assertEquals(SetupDestination.UserList(server), destination) + assertNull(repository.current.value) + verify(exactly = 0) { api.update(any(), any()) } + } + + private fun loadFreshShell(): ShellState { + val session = WolfskinSession() + val fresh = WolfskinShellViewModel(rule.activity, session, repository, navigation, + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), + WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), FakeUpdateController(), restoredReturn) + shellModel = fresh + runBlocking { fresh.load(server, requireNotNull(repository.current.value).user) } + return requireNotNull(session.state) + } + + private fun render(shell: ShellState) { + shell.enterDestination(titleRoute("Locke")) + val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + val scene = TitleState(fixture.title("Locke", shell.person.key), shell.person) {} + rule.setContent { WolfskinTheme { TitleSurface(scene, shell) } } + } + + private fun sameTitleAfterProcessDeath(automaticSignIn: Boolean, capture: String = automaticSignIn.toString()) { + automatic.value = automatic.value.update { signInAutomatically = automaticSignIn } + restore() + val current = requireNotNull(repository.current.value) + assertEquals(SetupDestination.AppContent(current), destination) + assertEquals(server.id, current.server.id) + assertEquals(user.id, current.user.id) + assertEquals(title, navigation.backStack.last()) + val shell = loadFreshShell() + render(shell) + rule.onNodeWithTag("title-heading").assertIsDisplayed().assertTextEquals("Locke") + rule.onNodeWithText("Who’s watching?").assertDoesNotExist() + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "trailer-return").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "process-restored-title-$capture.png")) + } + + @Test fun processDeathReturnsToTheSamePersonAndTitleWithAutomaticSignInOff() = sameTitleAfterProcessDeath(false) + @Test fun processDeathDoesNotReopenTheFreshShellChooserWithAutomaticSignInOn() = sameTitleAfterProcessDeath(true) + + @Test fun protectionAddedWhileTheProcessWasDeadStillRequiresUnlocking() { + val saved = snapshot() + for (protected in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true))) { + destination = SetupDestination.Loading + dao.updateUser(protected) + restore(saved) + assertGated() + } + } + + @Test fun aDifferentMostRecentPersonCannotReplaceTheTrailerIdentityEvenWithAutomaticSignInOn() { + val saved = snapshot() + val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token")) + recentValue = MostRecentServer.from(CurrentUser(server, other)) + for (enabled in listOf(false, true)) { + destination = SetupDestination.Loading + automatic.value = automatic.value.update { signInAutomatically = enabled } + restore(saved) + assertGated() + } + } + + @Test fun anExistingPickerCannotBeBypassedByATrailerSnapshot() { + destination = SetupDestination.UserList(server) + automatic.value = automatic.value.update { signInAutomatically = true } + restore() + assertGated() + } + + @Test fun missingTokenCannotBeRecoveredFromTheSavedTask() { + val saved = snapshot() + dao.updateUser(user.copy(accessToken = null)) + restore(saved) + assertGated() + } + + @Test fun removedPeopleAreNotRecreatedByTheSavedTask() { + val saved = snapshot() + dao.deleteUser(server.id, user.id) + restore(saved) + assertGated() + } + + @Test fun aFreshLaunchCannotUseAPreviousTrailerSnapshot() { + restore(snapshot(), resuming = false) + assertGated() + } + + @Test fun aFreshColdLaunchWithoutSavedTaskStateKeepsThePicker() { + restore(saved = null) + assertGated() + } + + @Test fun aReturnHoursLaterCannotRestoreThePerson() { + val saved = snapshot() + elapsed += 4L * 60L * 60L * 1000L + restore(saved) + assertGated() + } + + @Test fun aDifferentRestoredDestinationCannotReuseTheIdentityRecord() { + val saved = snapshot() + navigation.backStack[navigation.backStack.lastIndex] = Destination.Wolfskin(titleRoute("another-title")) + restore(saved) + assertGated() + } + + @Test fun automaticSignInWithoutATrailerRecordStillShowsTheColdChooser() { + automatic.value = automatic.value.update { signInAutomatically = true } + restore(saved = null) + render(loadFreshShell()) + rule.onNodeWithText("Who’s watching?").assertIsDisplayed() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun expiryDuringProfileLookupCannotInstallTheSavedToken() { + val saved = snapshot() + every { dao.getServer(server.id) } answers { + elapsed += 600_000L + callOriginal() + } + restore(saved) + assertGated() + } + + @Test fun restoringATrailerTaskDoesNotReplayItsOriginalLaunchIntent() { + coEvery { intents.parseIntent(any()) } returns IntentResult.Error("Old launch target expired") + model.appStart(Intent("old.launch.target"), resuming = true, savedState = snapshot()) + rule.waitUntil(15_000) { + shadowOf(Looper.getMainLooper()).idle() + destination != SetupDestination.Loading + } + assertEquals(SetupDestination.AppContent(requireNotNull(repository.current.value)), destination) + assertEquals(user.id, repository.current.value!!.user.id) + assertEquals(title, navigation.backStack.last()) + } + + @Test fun aNewExplicitLaunchCancelsAnOlderPendingTaskRestore() { + val started = CompletableDeferred() + val release = CompletableDeferred() + var firstRead = true + every { preferences.data } returns kotlinx.coroutines.flow.flow { + if (firstRead) { + firstRead = false + started.complete(Unit) + release.await() + } + emit(automatic.value) + } + model.appStart(null, resuming = true, savedState = snapshot()) + rule.waitUntil(15_000) { + shadowOf(Looper.getMainLooper()).idle() + started.isCompleted + } + model.appStart(null) + release.complete(Unit) + rule.waitUntil(15_000) { + shadowOf(Looper.getMainLooper()).idle() + destination != SetupDestination.Loading + } + assertGated() + } + + private fun authenticateSavedPerson(person: JellyfinUser = user) { + recentValue = MostRecentServer.from(CurrentUser(server, person)) + coEvery { recent.save(any()) } coAnswers { + recentValue = MostRecentServer.from(firstArg()) + } + coEvery { users.getCurrentUser() } returns successResponse(UserDto(id = person.id, name = person.name, + hasPassword = false, hasConfiguredPassword = false, hasConfiguredEasyPassword = false)) + runBlocking { check(repository.restoreLastSession() is RestoredSession.Success) } + clearMocks(api, answers = false, recordedCalls = true) + } + + @Test fun aRecommendationsWorkerRestoredPersonReturnsToTheSameTitleWithoutSigningInAgain() { + authenticateSavedPerson() + sameTitleAfterProcessDeath(false, capture = "worker") + verify(exactly = 0) { api.update(any(), any()) } + } + + @Test fun aWorkerRestoredPersonWithoutTrailerMetadataCannotSkipTheColdPicker() { + authenticateSavedPerson() + restore(saved = null) + assertEquals(SetupDestination.UserList(server), destination) + assertEquals(user.id, repository.current.value!!.user.id) + verify(exactly = 0) { api.update(any(), any()) } + } + + @Test fun aDifferentCurrentWorkerIdentityCannotBeReplacedByTheCapturedPerson() { + val saved = snapshot() + val other = dao.addOrUpdateUser(user.copy(rowId = 0, id = UUID.randomUUID(), name = "Mindel", accessToken = "another-token")) + authenticateSavedPerson(other) + recentValue = MostRecentServer.from(CurrentUser(server, user)) + restore(saved) + assertEquals(SetupDestination.UserList(server), destination) + assertEquals(other.id, repository.current.value!!.user.id) + verify(exactly = 0) { api.update(any(), any()) } + } + + @Test fun aProtectedCurrentWorkerIdentityStillRequiresUnlocking() { + authenticateSavedPerson() + val saved = snapshot() + for ((pin, login) in listOf("URDL" to false, null to true)) { + destination = SetupDestination.Loading + runBlocking { repository.updateUserAuth(user, pin, login) } + restore(saved) + assertEquals(SetupDestination.UserList(server), destination) + assertTrue(repository.current.value!!.user.isProtected) + verify(exactly = 0) { api.update(any(), any()) } + } + } + + @Test fun clearingTheSavedLoginInvalidatesAnOtherwiseEligibleTaskSnapshot() { + val saved = snapshot() + recentValue = MostRecentServer.Server(server.id, server.url) + restore(saved) + assertGated() + } + + @Test fun aCachedPersonCannotDismissAnExplicitPickerWithSavedTrailerMetadata() { + authenticateSavedPerson() + val saved = snapshot() + destination = SetupDestination.UserList(server) + automatic.value = automatic.value.update { signInAutomatically = true } + restore(saved) + assertEquals(SetupDestination.UserList(server), destination) + assertEquals(user.id, repository.current.value!!.user.id) + verify(exactly = 0) { api.update(any(), any()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt index c2138f15..0b7bb5fe 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -45,7 +45,7 @@ class SignedInContentTest { val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij") val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } val title = TitleState(data, shell.person) { action -> - if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url)) + if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {}) } rule.setContent { WolfskinTheme { diff --git a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index d8fe5695..09b9986a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -72,6 +72,7 @@ class TestMainActivityViewModel { backdropService, appUpgradeHandler, intentService, + trailerReturn = mockk(relaxed = true), ) private val serverId = UUID.randomUUID() @@ -88,6 +89,7 @@ class TestMainActivityViewModel { @Before fun setUp() { WholphinDispatchers.configure(testDispatcher) + every { setupNavigationManager.backStack } returns mutableListOf(SetupDestination.Loading) every { appUpgradeHandler.needUpgrade() } returns false every { appUpgradeHandler.copySubfont(any()) } returns Unit mockkStatic(Toast::class) @@ -167,14 +169,22 @@ class TestMainActivityViewModel { @Test fun savedColdLaunchNavigatesBeforeSessionRevalidationCompletes() = runTest(testDispatcher) { setupPreferences { signInAutomatically = true } - every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSavedSession() } returns RestoredSession.Success(currentUser) + val canonical = MutableStateFlow(null) + every { serverRepository.current } returns canonical + coEvery { serverRepository.restoreSavedSession() } coAnswers { + canonical.value = currentUser + RestoredSession.Success(currentUser) + } val release = CompletableDeferred() - coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { release.await() } + val validationStarted = CompletableDeferred() + coEvery { serverRepository.revalidateSession(currentUser) } coAnswers { + validationStarted.complete(Unit) + release.await() + } viewModel.handleAppStart() runCurrent() verify { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) } - assertFalse(release.isCompleted) + Assert.assertTrue(validationStarted.isCompleted) release.complete(Unit) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index ad3d4647..2c1d5fee 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -88,7 +88,7 @@ class AnimeAvailabilityTest { } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 125a67bd..0cfd7ef2 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -69,7 +69,7 @@ class AnimeRootRemovalTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { model.load(server, user) requireNotNull(model.onForeground(server, user)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index 052aa05c..bfa997eb 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest { val client = session.identity.value!!.client val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { requireNotNull(model.onForeground(server, user)).join() assertNull(libraries.audiences) @@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest { session.authenticate() val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { val identity = session.identity.value!! val intent = org.justinvisser.wolfskin.design.DiscoverRequest( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index 8fad3a82..edc7c984 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -50,7 +50,7 @@ class ConductarrTitleSurfaceTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true)) + val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val revokedLookup = CompletableDeferred() try { val route = titleRoute("movie:42") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index 2c956245..d8057fdb 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -60,7 +60,7 @@ class DiscoverRefreshMotionTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", false) val route = titleRoute("tv:44") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } @@ -112,7 +112,7 @@ class DiscoverRefreshMotionTest { @Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() { ConductarrSession(MemoryCacheStore()).use { session -> val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity), - NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) + NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", true) val route = titleRoute("movie-the-amazing-spider-man-2012") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } @@ -166,7 +166,7 @@ class DiscoverRefreshMotionTest { val facade = mockk() coEvery { facade.readSaved(any(), any()) } returns null coEvery { facade.read(any(), any(), any()) } returns held - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", false) val route = titleRoute(id.toString()) val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt index 2162701a..e1d8c0e7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -71,7 +71,7 @@ class DiscoverTitleTimingTest { val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) } - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply { pickerVisible = false; pickerLaunch = false; enterDestination("discover") } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index c412265e..98d25a86 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -91,7 +91,7 @@ class FirstSignInNavigationTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 35c5cb57..6fe1b9f1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -85,7 +85,7 @@ class PersonPickerSessionTest { val navigation = remember { NavigationManager() } val model = remember { WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)).also { models.add(it) } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 2ae2763a..d9d8a359 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -87,7 +87,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) val clients = mutableListOf() try { model.load(server, justin) @@ -163,7 +163,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { model.load(server, justin) requireNotNull(model.onForeground(server, justin)).join() @@ -220,7 +220,7 @@ class PersonSwitchThreadingTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) - val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true)) + val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) try { conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) conductarr.authenticate() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 4c195596..798a3d89 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -63,7 +63,7 @@ class SavedPeoplePickerTest { val session = WolfskinSession() val navigation = NavigationManager() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) try { rule.setContent { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index 8a01861d..56b9cd2a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -66,7 +66,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) val load = launch { model.load(server, user) } try { withTimeout(5_000) { savedPeopleStarted.await() } @@ -121,7 +121,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) val pending = CompletableDeferred() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index 55f81ce4..215c1582 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -60,7 +60,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readSaved(id, any()) } returns null coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val person = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) @@ -103,7 +103,7 @@ class TitleAsyncFocusTest { val facade = mockk() coEvery { facade.readPersonSaved(id) } returns null coEvery { facade.readPerson(id, 0) } coAnswers { reply.await() } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = personRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) @@ -150,7 +150,7 @@ class TitleAsyncFocusTest { reads.incrementAndGet() throw java.io.IOException("offline") } - viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true)) + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val viewer = ShellPerson("justin", "Justin", "J", false) val route = titleRoute(id.toString()) navigation.navigateTo(Destination.Wolfskin(route)) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt index 977af5ca..a4bb7f27 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -83,7 +83,7 @@ class TitleEntryBackTest { val source = WolfskinHomeViewModel(rule.activity, mockk(relaxed = true), mockk(), navigation, WolfskinSeeAllLists(), conductarr) homeModel = source viewModel = WolfskinTitleViewModel(rule.activity, mockk(), TitleFixtureProvider(rule.activity), navigation, conductarr, - mockk(relaxed = true), mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { val onBack = rememberWolfskinBackHandler(shell, navigation) @@ -183,7 +183,7 @@ class TitleEntryBackTest { } conductarr = ConductarrSession(MemoryCacheStore()) viewModel = WolfskinTitleViewModel(activity, facade, mockk(), navigation, conductarr, - mockk(relaxed = true), mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) shell.enterDestination(route) navigation.navigateTo(destination) val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie"))))) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 1838f45c..e33ba672 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -55,7 +55,7 @@ class TitlePlaybackNavigationTest { episodes = listOf(first, second), related = listOf(series), cast = listOf(TitlePerson("actor", "Actor"))) } val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) fun open(id: UUID) = model.load(titleRoute(id.toString()), person, preferences, fixtures = false, force = true) try { navigation.navigateTo(Destination.Wolfskin(titleRoute(seriesId.toString()))) @@ -109,7 +109,7 @@ class TitlePlaybackNavigationTest { coEvery { facade.preparePlayback(firstId) } returns BaseItemDto(id = firstId, type = BaseItemKind.EPISODE, userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) try { model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) advanceUntilIdle() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt index d022a821..051f2e4e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt @@ -46,7 +46,7 @@ class TitleRequestSafetyTest { runBlocking { session.authenticate() } val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null - val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true)) + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) val person = ShellPerson("shared", "Shared", "S", true) val route = titleRoute("movie:42") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt index f2bcdf90..d99aa9ab 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt @@ -162,7 +162,7 @@ class TitleThemeMusicTest { coEvery { facade.preparePlayback(episodeId) } returns BaseItemDto(id = episodeId, type = BaseItemKind.EPISODE) val conductarr = ConductarrSession(MemoryCacheStore()).also { session = it } val titleModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, conductarr, - mockk(relaxed = true), playback.player).also { model = it } + mockk(relaxed = true), playback.player, trailerReturn = mockk(relaxed = true)).also { model = it } rule.runOnIdle { navigation.navigateTo(destination) } rule.setContent { WolfskinTheme { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt index b2b6b3e6..ac7af2db 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleTrailerTest.kt @@ -23,13 +23,13 @@ class TitleTrailerTest { @Test fun missingYoutubeHandlerStaysOnTheTitleEvenWhenABrowserExists() { register(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=ab_-defghij")), "test.browser") - assertFalse(TitleTrailer(context).open(url)) + assertFalse(TitleTrailer(context).open(url) {}) assertNull(shadowOf(context as Application).nextStartedActivity) } @Test fun installedYoutubeHandlerReceivesAnExplicitPackageAndNoBrowserUrl() { register(Intent(Intent.ACTION_VIEW, Uri.parse("vnd.youtube://ab_-defghij")), "test.youtube") - assertTrue(TitleTrailer(context).open(url)) + assertTrue(TitleTrailer(context).open(url) {}) val launched = shadowOf(context as Application).nextStartedActivity assertEquals(Intent.ACTION_VIEW, launched.action) assertEquals("test.youtube", launched.`package`) @@ -40,7 +40,7 @@ class TitleTrailerTest { @Test fun nonYoutubeTrailerNeverOpensAGeneralViewHandler() { register(Intent(Intent.ACTION_VIEW, Uri.parse("https://trailers.example/video")), "test.browser") - assertFalse(TitleTrailer(context).open("https://trailers.example/video")) + assertFalse(TitleTrailer(context).open("https://trailers.example/video") {}) assertNull(shadowOf(context as Application).nextStartedActivity) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt new file mode 100644 index 00000000..e2d42700 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TrailerReturnStateTest.kt @@ -0,0 +1,150 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Bundle +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.SavedSessionIdentity +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** A saved task may carry only a short-lived, exact, unprotected trailer identity; not a generic login. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class TrailerReturnStateTest { + private var elapsed = 1_000L + private var boot = 7 + private val clock = mockk().also { + every { it.readElapsedMillis() } answers { elapsed } + every { it.readBootCount() } answers { boot } + } + private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "never-serialize-this-token") + private val current = CurrentUser(server, user) + private val title = Destination.Wolfskin(titleRoute("Locke")) + private val identity = SavedSessionIdentity(server.id, user.id) + + private fun snapshot(person: CurrentUser = current): Bundle { + val originalProcess = TrailerReturnState(clock) + originalProcess.prepare(person, title) + return Bundle().also { originalProcess.save(it, person, title) } + } + + @Test fun aNewProcessCanRecoverTheExactTaskIdentityWithoutCopyingCredentials() { + val saved = snapshot() + assertFalse(saved.getString(TrailerReturnState.SAVED_STATE_KEY)!!.contains(user.accessToken!!)) + elapsed += 3_000L + assertEquals(identity, TrailerReturnState(clock).restore(saved, title)) + } + + @Test fun freshLaunchAndCancelledHandoffHaveNoRestorationCapability() { + val state = TrailerReturnState(clock) + state.prepare(current, title) + assertNull(state.restore(null, title)) + state.prepare(current, title) + state.clear() + val saved = Bundle().also { state.save(it, current, title) } + assertNull(TrailerReturnState(clock).restore(saved, title)) + } + + @Test fun theReturnWindowExpiresAtTenMinutesAndRejectsFutureTimestamps() { + for ((age, permitted) in listOf(599_999L to true, 600_000L to false, 14_400_000L to false, -1L to false)) { + elapsed = 1_000L + val saved = snapshot() + elapsed += age + assertEquals("age=$age", if (permitted) identity else null, TrailerReturnState(clock).restore(saved, title)) + } + } + + @Test fun savingAnOlderHandoffDoesNotRenewItsAuthorizationTime() { + val state = TrailerReturnState(clock) + state.prepare(current, title) + elapsed += 599_999L + val saved = Bundle().also { state.save(it, current, title) } + elapsed += 1L + assertNull(TrailerReturnState(clock).restore(saved, title)) + } + + @Test fun differentAndUnknownBootsCannotRestoreATask() { + for ((initial, returning) in listOf(7 to 8, 7 to -1, -1 to 7)) { + boot = initial + val saved = snapshot() + boot = returning + assertNull(TrailerReturnState(clock).restore(saved, title)) + } + } + + @Test fun protectedAndTokenlessPeopleCannotCreateAHandoffRecord() { + for (person in listOf(user.copy(pin = "URDL"), user.copy(requireLogin = true), user.copy(accessToken = null))) { + val saved = snapshot(current.copy(user = person)) + assertNull(TrailerReturnState(clock).restore(saved, title)) + } + } + + @Test fun aPersonProtectionOrDestinationChangeInvalidatesTheTaskSnapshot() { + val otherServer = server.copy(id = UUID.randomUUID()) + val changes = listOf( + current.copy(user = user.copy(id = UUID.randomUUID())) to title, + CurrentUser(otherServer, user.copy(serverId = otherServer.id)) to title, + current.copy(user = user.copy(pin = "URDL")) to title, + current to Destination.Wolfskin(titleRoute("another-title")), + ) + for ((person, destination) in changes) { + val state = TrailerReturnState(clock) + state.prepare(current, title) + val saved = Bundle().also { state.save(it, person, destination) } + assertNull(TrailerReturnState(clock).restore(saved, destination)) + } + } + + @Test fun anotherRecoveredTitleAndMalformedStateDoNotAuthorizeRestoration() { + assertNull(TrailerReturnState(clock).restore(snapshot(), Destination.Wolfskin(titleRoute("another-title")))) + val malformed = Bundle().apply { putString(TrailerReturnState.SAVED_STATE_KEY, "not JSON") } + assertNull(TrailerReturnState(clock).restore(malformed, title)) + } + + @Test fun skippingTheColdChooserRequiresConfirmedIdentityAndIsOneUse() { + val state = TrailerReturnState(clock) + val saved = snapshot() + assertEquals(identity, state.restore(saved, title)) + assertFalse(state.consumeRestoredSession(current)) + state.confirmRestore(current) + assertTrue(state.consumeRestoredSession(current)) + assertFalse(state.consumeRestoredSession(current)) + } + + @Test fun confirmationCannotDismissTheChooserForAnotherOrNowProtectedPerson() { + for (person in listOf(user.copy(id = UUID.randomUUID()), user.copy(pin = "URDL"), user.copy(requireLogin = true))) { + val state = TrailerReturnState(clock) + state.restore(snapshot(), title) + val changed = current.copy(user = person) + state.confirmRestore(changed) + assertFalse(state.consumeRestoredSession(changed)) + } + } + + @Test fun aNormalResumeStopsLaterUnrelatedSavesFromReusingTheHandoff() { + val state = TrailerReturnState(clock) + state.prepare(current, title) + state.finishHandoff() + val saved = Bundle().also { state.save(it, current, title) } + assertNull(TrailerReturnState(clock).restore(saved, title)) + } + + @Test fun aRecreatedActivityResumeDoesNotDiscardItsValidatedRestoration() { + val state = TrailerReturnState(clock) + state.restore(snapshot(), title) + state.finishHandoff() + assertTrue(state.isRestoring(identity)) + state.confirmRestore(current) + assertTrue(state.consumeRestoredSession(current)) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt index 6e6c42ed..bcc17544 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt @@ -171,7 +171,7 @@ class WolfskinRootExitTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() conductarr = ConductarrSession(MemoryCacheStore()) model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController(), trailerReturn = mockk(relaxed = true)) val home = HomeFixture(activity).snapshot("justin") rule.setContent { WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index 6a2f29b7..f647b914 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.datastore.core.DataStore import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch +import kotlinx.coroutines.async +import kotlinx.coroutines.Dispatchers import com.github.damontecres.wholphin.MainActivityViewModel import com.github.damontecres.wholphin.MainContent import com.github.damontecres.wholphin.R @@ -105,7 +107,7 @@ class WolfskinSetupNavigationTest { } private fun appModel() = MainActivityViewModel(rule.activity, preferences, repository, setup, navigation, - mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService) + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), intentService, trailerReturn = mockk(relaxed = true)) private fun awaitStage(stage: WolfskinSetupViewModel.Stage) { rule.waitUntil(5_000) { @@ -115,6 +117,16 @@ class WolfskinSetupNavigationTest { } } + private fun startApp() { + val startupModel = appModel() + val result = rule.activity.lifecycleScope.async(Dispatchers.Default) { startupModel.handleAppStart() } + rule.waitUntil(15_000) { + Shadows.shadowOf(Looper.getMainLooper()).idle() + result.isCompleted + } + runBlocking { result.await() } + } + private fun assertOwnPicker() { awaitStage(WolfskinSetupViewModel.Stage.Picker) rule.onNodeWithText("Who’s watching?").assertIsDisplayed() @@ -124,7 +136,7 @@ class WolfskinSetupNavigationTest { } @Test fun coldStartLegacySignOutDrawerSwitchAndIntentFailureNeverComposeStockSelectors() { - runBlocking { appModel().handleAppStart() } + startApp() mount() assertOwnPicker() // The legacy sign-out-equivalent erased the recent preference, but Room still has the household. @@ -145,7 +157,7 @@ class WolfskinSetupNavigationTest { val saved = listOf(user, user.copy(id = UUID.randomUUID(), name = "Mindel"), user.copy(id = UUID.randomUUID(), name = "Shared")) every { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, saved) - runBlocking { appModel().handleAppStart() } + startApp() mount() assertOwnPicker() saved.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index aeb7f687..182bf06c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -71,6 +71,7 @@ class WolfskinTitleViewModel @Inject constructor( val conductarr: ConductarrSession, private val repository: ServerRepository, val themeSongPlayer: ThemeSongPlayer, + private val trailerReturn: TrailerReturnState, ) : ViewModel() { var title by mutableStateOf(null) private set @@ -272,7 +273,13 @@ class WolfskinTitleViewModel @Inject constructor( is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) else { themeSongPlayer.stop() - if (!TitleTrailer(context).open(action.url)) title?.toast = "No YouTube app is available to play this trailer." + val launched = TitleTrailer(context).open(action.url) { + trailerReturn.prepare(repository.current.value, navigation.backStack.lastOrNull()) + } + if (!launched) { + trailerReturn.clear() + title?.toast = "No YouTube app is available to play this trailer." + } } is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt index 7cf330d3..a46c6a0a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleTrailer.kt @@ -9,7 +9,7 @@ import android.net.Uri /** Only an installed app that handles the YouTube scheme may receive a remote trailer. */ class TitleTrailer(private val context: Context) { /** A missing handler leaves the title in place rather than falling back to a browser. */ - fun open(url: String): Boolean { + fun open(url: String, beforeLaunch: () -> Unit): Boolean { val source = Uri.parse(url) val video = when (source.host?.lowercase()) { "youtu.be" -> source.pathSegments.firstOrNull() @@ -23,6 +23,7 @@ class TitleTrailer(private val context: Context) { .firstOrNull { it.activityInfo.exported } ?: return false intent.setPackage(handler.activityInfo.packageName) return try { + beforeLaunch() context.startActivity(intent) true } catch (_: ActivityNotFoundException) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt new file mode 100644 index 00000000..868871ee --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt @@ -0,0 +1,145 @@ +package org.justinvisser.wolfskin.tv + +import android.content.Context +import android.os.Bundle +import android.os.SystemClock +import android.provider.Settings +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.SavedSessionIdentity +import com.github.damontecres.wholphin.ui.nav.Destination +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +/** Elapsed time includes sleep; a missing boot counter cannot authorize task restoration. */ +class TrailerReturnClock @Inject constructor(@param:ApplicationContext private val context: Context) { + /** A wall-clock adjustment must not extend the return window. */ + fun readElapsedMillis(): Long = SystemClock.elapsedRealtime() + + /** Older or restricted systems retain the picker when same-boot evidence is unavailable. */ + fun readBootCount(): Int = try { + Settings.Global.getInt(context.contentResolver, Settings.Global.BOOT_COUNT, -1) + } catch (_: SecurityException) { + -1 + } +} + +/** Only an authenticated, unprotected trailer hand-off may supply a short-lived saved-task identity. */ +@Singleton +class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClock) { + private var pending: TrailerReturnRecord? = null + private var restoring: TrailerReturnRecord? = null + private var confirmed: TrailerReturnRecord? = null + + /** Capture authorization before handing the activity to the external app; never capture a PIN login. */ + @Synchronized + fun prepare(current: CurrentUser?, destination: Destination?) { + clear() + val route = titleDestination(destination) ?: return + if (current == null || current.user.isProtected || current.user.accessToken.isNullOrBlank()) return + val elapsed = clock.readElapsedMillis() + val boot = clock.readBootCount() + if (elapsed < 0 || boot < 0) return + pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot) + } + + /** A task snapshot cannot carry an old identity after a person/protection/destination change. */ + @Synchronized + fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) { + outState.remove(SAVED_STATE_KEY) + val record = pending ?: return + if (!matches(record, current) || titleDestination(destination) != record.route || !isWithinWindow(record)) { + pending = null + return + } + outState.putString(SAVED_STATE_KEY, Json.encodeToString(record)) + } + + /** Saved navigation is insufficient without the matching, unexpired trailer hand-off record. */ + @Synchronized + fun restore(savedState: Bundle?, destination: Destination?): SavedSessionIdentity? { + clear() + val encoded = savedState?.getString(SAVED_STATE_KEY) ?: return null + val record = try { + Json.decodeFromString(encoded) + } catch (_: SerializationException) { + return null + } + if (record.route != titleDestination(destination) || !isWithinWindow(record)) return null + val server = record.serverId.toUUIDOrNull() ?: return null + val user = record.userId.toUUIDOrNull() ?: return null + restoring = record + return SavedSessionIdentity(server, user) + } + + /** The shell may skip its cold chooser only after the repository has accepted the exact identity. */ + @Synchronized + fun confirmRestore(current: CurrentUser) { + val record = restoring + restoring = null + confirmed = record?.takeIf { matches(it, current) && isWithinWindow(it) } + } + + @Synchronized + fun hasConfirmedRestore(): Boolean = confirmed != null + + /** This one-use UI hand-off never authenticates anyone and cannot select a different person. */ + @Synchronized + fun consumeRestoredSession(current: CurrentUser?): Boolean { + val record = confirmed + confirmed = null + return record != null && matches(record, current) && isWithinWindow(record) + } + + /** A cancelled startup or slow lookup must not install credentials after its return window closes. */ + @Synchronized + fun isRestoring(expected: SavedSessionIdentity): Boolean = + restoring?.let { + it.serverId == expected.serverId.toString() && it.userId == expected.userId.toString() && isWithinWindow(it) + } == true + + /** Translucent handlers can return through onResume without onRestart; do not reuse their capture. */ + @Synchronized + fun finishHandoff() { + pending = null + } + + /** Normal return and explicit launches must not lend their marker to unrelated later saves. */ + @Synchronized + fun clear() { + pending = null + restoring = null + confirmed = null + } + + private fun matches(record: TrailerReturnRecord, current: CurrentUser?): Boolean = + current != null && !current.user.isProtected && !current.user.accessToken.isNullOrBlank() && + current.server.id.toString() == record.serverId && current.user.id.toString() == record.userId + + private fun isWithinWindow(record: TrailerReturnRecord): Boolean { + if (record.startedElapsed < 0 || record.bootCount < 0) return false + val age = clock.readElapsedMillis() - record.startedElapsed + return age >= 0 && age < RETURN_WINDOW_MILLIS && clock.readBootCount() == record.bootCount + } + + private fun titleDestination(destination: Destination?): String? = + destination?.toWolfskinRoute()?.takeIf { it.startsWith("title/") } + + companion object { + internal const val SAVED_STATE_KEY = "wolfskin.trailer.return" + private const val RETURN_WINDOW_MILLIS = 10L * 60L * 1000L + } +} + +@Serializable +private data class TrailerReturnRecord( + val serverId: String, + val userId: String, + val route: String, + val startedElapsed: Long, + val bootCount: Int, +) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 7e39a508..0f8bdc4d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor( val conductarr: ConductarrSession, artwork: WolfskinArtwork, val updates: org.justinvisser.wolfskin.tv.update.UpdateController, + private val trailerReturn: TrailerReturnState, ) : ViewModel() { init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow @@ -107,10 +108,20 @@ class WolfskinShellViewModel @Inject constructor( session.serverId = server.id.toString() session.fixtureMode = ConductarrPreference.fixtures val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) } - session.state = ShellState(people, signedInPerson) { session.onAction(it) } + val continueTrailer = trailerReturn.hasConfirmedRestore() && trailerReturn.consumeRestoredSession(repository.current.value?.takeIf { + it.server.id == server.id && it.user.id == user.id + }) + session.state = ShellState(people, signedInPerson) { session.onAction(it) }.apply { + if (continueTrailer) { + pickerVisible = false + pickerLaunch = false + } + } val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() } val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) } session.state?.updatePeople(savedPeople) + } else { + trailerReturn.consumeRestoredSession(null) } identityJob = viewModelScope.launch { if (!ConductarrPreference.fixtures) loadHousehold(user) diff --git a/docker/README.md b/docker/README.md index 5522b541..f07b3eb5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -449,11 +449,11 @@ only when the Advanced debug fixture switch is enabled; release excludes it. ### Household setup and sign-in recovery With **Wolfskin screens** enabled, `MainContent` intercepts both upstream setup -destinations before their stock server/user selectors can render. Cold start, -restart with automatic sign-in disabled, the old drawer's user action, legacy -sign-out-equivalent state, setup fallbacks and intent failures all use Wolfskin's -own picker. Entering it does not call `SwitchUserViewModel.init` or clear the -remembered server/user preference. +destinations before their stock server/user selectors can render. Cold launches +that require sign-in, protected-profile restarts, the old drawer's user action, +legacy sign-out-equivalent state, setup fallbacks and intent failures all use +Wolfskin's own picker. Entering it does not call `SwitchUserViewModel.init` or +clear the remembered server/user preference. Setup reuses the supplied/current/recent household server, recovering the most recently used Room server if the legacy picker erased the preference. Saved people @@ -469,6 +469,35 @@ session. A failed selection offers Wolfskin **Try again** and **Sign in** action hidden/unsaved profiles can use username/password sign-in through the setup picker's Settings gear. An outage never replaces the saved household endpoint. +Returning from an external app such as SmartTube keeps the originating title and +person when the same unprotected identity remains live and its signed-in app +entry is still active, even with automatic sign-in off. Live protection is checked +at stop and again when the stopped entry starts. + +If Android kills Wolfskin while the trailer is in front, the same saved Android +task can continue for **strictly less than ten minutes from the handoff, on the +same boot**. Only an exported YouTube-handler launch from an authenticated, +unprotected title creates this return record. It stores server/person IDs, the +exact title route, elapsed time and boot counter in the Activity's saved Bundle: +no token, password, PIN or new last-person preference. Saving/recreating does not +renew the deadline; missing boot evidence keeps the picker. + +Before installing a saved token, restoration re-reads the profile and requires +the exact most-recent server/person, a present token, no PIN and no require-login, +then rechecks the deadline. The recommendations worker may already have restored +that same unprotected canonical identity before setup leaves Loading; continuing +it does not install credentials again. A different current person, sign-out, +removed/tokenless profile or explicit picker cannot be replaced by the return +path. PIN/require-login people remain gated. A fresh task, fresh explicit intent, +wrong route or expired/missing record follows ordinary startup policy. + +After identity acceptance, a one-use confirmation lets only that person skip the +new shell's launch chooser and reach the saved title. Ordinary cold automatic +sign-in still opens the chooser. `MainActivityTrailerRestoreTest` and +`TrailerReturnStateTest` cover the process-death window and identity gates; +`MainActivityResumeTest` and `SignedInContentTest` cover live return and protection +changes while away. + Turning **Wolfskin screens** off retains the unchanged upstream `SwitchServerContent` and `SwitchUserContent` setup screens for rollback. Debug fixtures still seed the existing fixture picker after authentication. @@ -484,7 +513,7 @@ late reply cannot navigate back to an earlier person. The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its server and shell state, not to the user-scoped ViewModel. Switching people replaces the upstream app entry and ViewModel but retains the same-server, same-mode shell: -the picker stays closed on Home after the first OK. A cold start still opens it, +the picker stays closed on Home after the first OK. A fresh task still opens it, and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real shell and user-entry replacement for launch/avatar Left and Right, the initial person, gear and back, and fast Left-Left-OK; each selection emits one person action. -- 2.49.1 From 3da8a31c5bf8372c7c0b9c103985d1166bcd265d Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Nov 2026 07:01:50 +0100 Subject: [PATCH 14/17] Fix test constructor calls broken by merging the polish lanes with the trailer-return fix Test-only; product code is unchanged. NavigationCutTest passes the new liveSession to MainContent and trailerReturn to the shell view model; TitleEntryBackTest passes the session to the Home view model; TitlePolishTest passes trailerReturn to the title view model. --- .../java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt | 4 ++-- .../java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt | 2 +- .../test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index a8885d7f..cf2e1df3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -253,7 +253,7 @@ class NavigationCutTest { every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, + mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow(null), Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), setupInterceptor = { destination, modifier -> androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) @@ -356,7 +356,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule(relaxed = true), mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme { val route = titleRoute(id.toString()) ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) { -- 2.49.1 From da003d9ea4b68cf3b3bc86e78cd72dca38505c32 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 8 Nov 2026 07:17:07 +0100 Subject: [PATCH 15/17] Give the app test JVM a 2g heap --- app/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 05c2e073..697ac9b6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -495,6 +495,8 @@ tasks.withType().configureEach { dependsOn(":wolfskin-design:testDebugUnitTest") // Android 16 shared-memory setup needs Robolectric's JDK FileDescriptor bridge. jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") + // The full suite keeps every Robolectric capture in one worker; the 512 MB default runs out near the end. + maxHeapSize = "2g" systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath) systemProperty("robolectric.offline", "true") systemProperty("roborazzi.test.record", "true") -- 2.49.1 From fa08235ac4d4a986aae18882c714306080fbc62d Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 08:02:37 +0100 Subject: [PATCH 16/17] Wait for Settings scroll idle before asserting the retained row --- .../java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt index a358b627..3d1ed93a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt @@ -92,6 +92,8 @@ class UpdatesKeysTest { launch(UpdateUiState.Checking, settings = true) repeat(9) { press(listOf(Key.DirectionDown)) } assertEquals(9, shell.settingsRow) + // Visual settling precedes the spring's idle state and final row boundary. + rule.mainClock.advanceTimeUntil(timeoutMillis = 320) { !shell.view.listState.isScrollInProgress } assertEquals(4, shell.view.listState.firstVisibleItemIndex) capture("classic") press(listOf(Key.DirectionCenter)) -- 2.49.1 From 4a6d318f8f4d8d93c82d509ae7c54e83cff4f480 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 13:08:05 +0100 Subject: [PATCH 17/17] Limit auto-off warm continuation to trailer handoffs --- .../wholphin/test/InstrumentedBasicUiTests.kt | 1 + .../damontecres/wholphin/MainActivity.kt | 2 +- .../wholphin/MainActivityViewModel.kt | 15 ++- .../damontecres/wholphin/MainContent.kt | 5 +- .../damontecres/wholphin/SignedInContent.kt | 11 +- .../wholphin/MainActivityResumeTest.kt | 114 +++++++++++++++++- .../wholphin/SignedInContentTest.kt | 80 +++++++++++- .../wolfskin/tv/NavigationCutTest.kt | 2 +- .../tv/WolfskinSetupNavigationTest.kt | 2 +- .../wolfskin/tv/WolfskinSetupRollbackTest.kt | 2 +- .../wolfskin/tv/TrailerReturnState.kt | 11 +- docker/README.md | 11 +- 12 files changed, 232 insertions(+), 24 deletions(-) diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt index 5ccfa603..f3212c23 100644 --- a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -57,6 +57,7 @@ class InstrumentedBasicUiTests { backdropService = mockk(relaxed = true), screensaverService = screensaverService, liveSession = MutableStateFlow(null), + trailerReturn = mockk(relaxed = true), modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 66a2b040..dadf10c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -274,6 +274,7 @@ class MainActivity : AppCompatActivity() { backdropService = backdropService, screensaverService = screensaverService, liveSession = viewModel.serverRepository.current, + trailerReturn = trailerReturn, modifier = Modifier.fillMaxSize(), ) } @@ -310,7 +311,6 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() Timber.d("onRestart") - trailerReturn.clear() viewModel.appStart(null, resuming = true) if (!playExternalViewModel.launched.value) { // If restarting during playback that is not external, go back a page diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt index 271e47b6..ba2c44a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivityViewModel.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.justinvisser.wolfskin.tv.TrailerReturnRecord import org.justinvisser.wolfskin.tv.TrailerReturnState import org.justinvisser.wolfskin.tv.recoverWolfskinSession import timber.log.Timber @@ -51,6 +52,8 @@ class MainActivityViewModel private var startupJob: kotlinx.coroutines.Job? = null fun appStart(intent: Intent?, resuming: Boolean = false, savedState: Bundle? = null) { + // onResume clears the pending handoff before asynchronous startup necessarily runs. + val handoff = if (resuming && savedState == null) trailerReturn.pendingHandoff() else null if (!resuming) trailerReturn.clear() startupJob?.cancel() startupJob = viewModelScope.launchDefault { @@ -80,7 +83,7 @@ class MainActivityViewModel } try { - handleAppStart(resuming, savedState) + handleAppStart(resuming, savedState, handoff) } catch (ex: Exception) { if (ex is kotlinx.coroutines.CancellationException) throw ex Timber.e(ex, "Error during appStart") @@ -184,7 +187,11 @@ class MainActivityViewModel * Handle starting the app including auto sign in and navigating to the right starting page */ @VisibleForTesting - internal suspend fun handleAppStart(resuming: Boolean = false, savedState: Bundle? = null) { + internal suspend fun handleAppStart( + resuming: Boolean = false, + savedState: Bundle? = null, + handoff: TrailerReturnRecord? = trailerReturn.pendingHandoff(), + ) { val signInAutomatically = preferences.data.first().signInAutomatically val currentUser = serverRepository.current.value val owner = setupNavigationManager.backStack.lastOrNull() @@ -213,7 +220,9 @@ class MainActivityViewModel return } // A picker may be an explicit switch or expired login, even while a user remains cached. - if ((signInAutomatically || (resuming && canContinue)) && currentUser != null && !currentUser.user.isProtected) { + val continuingTrailer = resuming && savedState == null && canContinue && + trailerReturn.canContinue(handoff, currentUser, navigationManager.backStack.lastOrNull()) + if ((signInAutomatically || continuingTrailer) && currentUser != null && !currentUser.user.isProtected) { withContext(WholphinDispatchers.Main.immediate) { if (setupNavigationManager.backStack.lastOrNull() === owner && serverRepository.current.value === currentUser) { setupNavigationManager.navigateTo(SetupDestination.AppContent(currentUser)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index f0cff9d5..9f2183a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.util.InterfaceCustomization import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import kotlinx.coroutines.flow.StateFlow +import org.justinvisser.wolfskin.tv.TrailerReturnState /** Setup entries carry display snapshots; lifecycle privacy checks use the live repository identity. */ @Composable @@ -51,6 +52,7 @@ fun MainContent( backdropService: BackdropService, screensaverService: ScreensaverService, liveSession: StateFlow, + trailerReturn: TrailerReturnState, modifier: Modifier = Modifier, setupInterceptor: @Composable (SetupDestination, Modifier) -> Boolean = { key, contentModifier -> WolfskinSetupContent(key, contentModifier) }, ) { @@ -110,7 +112,8 @@ fun MainContent( backdropService.clearBackdrop() } val current = key.current - SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically) { + SignedInContent(current, liveSession, preferences.appPreferences.signInAutomatically, + trailerReturn, { navigationManager.backStack.lastOrNull() }) { WolfskinApp( user = current.user, server = current.server, diff --git a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt index 2d22ded4..781ac28a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/SignedInContent.kt @@ -16,27 +16,34 @@ import androidx.tv.material3.MaterialTheme import androidx.lifecycle.compose.LifecycleEventEffect import com.github.damontecres.wholphin.data.CurrentUser import kotlinx.coroutines.flow.StateFlow +import com.github.damontecres.wholphin.ui.nav.Destination +import org.justinvisser.wolfskin.tv.TrailerReturnRecord +import org.justinvisser.wolfskin.tv.TrailerReturnState -/** Keep an unprotected live session mounted across external-app hand-offs; protected people re-enter setup. */ +/** Only a pending trailer handoff keeps an auto-off session mounted across a stopped activity. */ @Composable internal fun SignedInContent( current: CurrentUser, liveSession: StateFlow, signInAutomatically: Boolean, + trailerReturn: TrailerReturnState, + destination: () -> Destination?, content: @Composable () -> Unit, ) { var showContent by remember(current.server.id, current.user.id) { mutableStateOf(true) } var hasStopped by remember(current.server.id, current.user.id) { mutableStateOf(false) } + var handoff by remember(current.server.id, current.user.id) { mutableStateOf(null) } fun hideInvalidSession() { if (!signInAutomatically) { val live = liveSession.value val canContinue = live != null && live.server.id == current.server.id && - live.user.id == current.user.id && !live.user.isProtected + live.user.id == current.user.id && trailerReturn.canContinue(handoff, live, destination()) if (!canContinue) showContent = false } } LifecycleEventEffect(Lifecycle.Event.ON_STOP) { hasStopped = true + handoff = trailerReturn.pendingHandoff() hideInvalidSession() } LifecycleEventEffect(Lifecycle.Event.ON_START) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt index ec5143c0..f9e853c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/MainActivityResumeTest.kt @@ -20,13 +20,16 @@ import java.util.UUID import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test +import org.justinvisser.wolfskin.tv.TrailerReturnClock +import org.justinvisser.wolfskin.tv.TrailerReturnState -/** Warm continuation retains only an already active, unprotected identity; cold and locked entries stay gated. */ +/** Only a valid trailer handoff continues an auto-off live identity; cold and locked entries stay gated. */ @OptIn(ExperimentalCoroutinesApi::class) class MainActivityResumeTest { private val dispatcher = StandardTestDispatcher() @@ -38,8 +41,16 @@ class MainActivityResumeTest { private val current = CurrentUser(server, JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token")) private val automatic = MutableStateFlow(AppPreferences.getDefaultInstance().update { signInAutomatically = false }) + private var elapsed = 1_000L + private var boot = 7 + private val clock = mockk().also { + every { it.readElapsedMillis() } answers { elapsed } + every { it.readBootCount() } answers { boot } + } + private val trailerReturn = TrailerReturnState(clock) + private val title = Destination.Wolfskin("title/${UUID.randomUUID()}") private val model = MainActivityViewModel(mockk(), preferences, repository, setup, navigation, - mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn = mockk(relaxed = true)) + mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), trailerReturn) @Before fun configure() { WholphinDispatchers.configure(dispatcher) @@ -52,16 +63,109 @@ class MainActivityResumeTest { @After fun reset() { WholphinDispatchers.reset() } - @Test fun warmReturnKeepsTheSignedInPersonAndTitleWithAutomaticSignInOff() = runTest(dispatcher) { + @Test fun ordinaryWarmReturnShowsThePickerWithAutomaticSignInOff() = runTest(dispatcher) { val title = Destination.Wolfskin("title/${UUID.randomUUID()}") navigation.backStack.add(title) model.handleAppStart(resuming = true) - verify { setup.navigateTo(SetupDestination.AppContent(current)) } - assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList()) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } coVerify(exactly = 0) { repository.restoreSavedSession() } + } + + @Test fun trailerReturnKeepsTheSamePersonAndTitleButTheNextOrdinaryReturnShowsThePicker() = runTest(dispatcher) { + navigation.backStack.add(title) + trailerReturn.prepare(current, title) + model.handleAppStart(resuming = true) + verify(exactly = 1) { setup.navigateTo(SetupDestination.AppContent(current)) } + assertEquals(listOf(Destination.Home(), title), navigation.backStack.toList()) + clearMocks(setup, answers = false) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + + @Test fun onResumeCleanupCannotEraseTheHandoffCapturedBeforeAsynchronousStartup() = runTest(dispatcher) { + navigation.backStack.add(title) + trailerReturn.prepare(current, title) + model.appStart(null, resuming = true) + trailerReturn.finishHandoff() + advanceUntilIdle() + verify { setup.navigateTo(SetupDestination.AppContent(current)) } coVerify(exactly = 0) { repository.getMostRecentServer() } } + @Test fun expiredOrDifferentBootHandoffsStillShowThePicker() = runTest(dispatcher) { + navigation.backStack.add(title) + for ((age, returningBoot) in listOf(600_000L to 7, 1L to 8, 1L to -1)) { + elapsed = 1_000L + boot = 7 + trailerReturn.prepare(current, title) + elapsed += age + boot = returningBoot + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + } + + @Test fun protectionAddedDuringAPendingHandoffStillRequiresUnlocking() = runTest(dispatcher) { + navigation.backStack.add(title) + for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) { + trailerReturn.prepare(current, title) + val protected = current.copy(user = user) + every { repository.current } returns MutableStateFlow(protected) + every { setup.backStack } returns mutableListOf(SetupDestination.AppContent(protected)) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(protected)) } + } + } + + @Test fun anotherPersonOrTitleCannotReuseAPendingHandoff() = runTest(dispatcher) { + val other = current.copy(user = current.user.copy(id = UUID.randomUUID())) + navigation.backStack.add(title) + trailerReturn.prepare(other, title) + model.handleAppStart(resuming = true) + trailerReturn.prepare(current, Destination.Wolfskin("title/${UUID.randomUUID()}")) + model.handleAppStart(resuming = true) + verify(exactly = 2) { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + + @Test fun ordinaryWarmReturnWithAutomaticSignInOnStillContinuesTheLivePerson() = runTest(dispatcher) { + automatic.value = automatic.value.update { signInAutomatically = true } + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.AppContent(current)) } + coVerify(exactly = 0) { repository.restoreSavedSession() } + } + + @Test fun coldLaunchCannotUseAPendingLiveHandoff() = runTest(dispatcher) { + navigation.backStack.add(title) + trailerReturn.prepare(current, title) + model.handleAppStart(resuming = false) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + + @Test fun anExplicitPickerCannotBeDismissedByAPendingLiveHandoff() = runTest(dispatcher) { + navigation.backStack.add(title) + trailerReturn.prepare(current, title) + every { setup.backStack } returns mutableListOf(SetupDestination.UserList(current.server)) + model.handleAppStart(resuming = true) + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + + @Test fun theHandoffDeadlineIsRecheckedAfterAsynchronousStartupHasWaited() = runTest(dispatcher) { + navigation.backStack.add(title) + trailerReturn.prepare(current, title) + model.appStart(null, resuming = true) + elapsed += 600_000L + advanceUntilIdle() + verify { setup.navigateTo(SetupDestination.UserList(current.server)) } + verify(exactly = 0) { setup.navigateTo(SetupDestination.AppContent(current)) } + } + @Test fun warmReturnCannotUnlockPinOrRequireLoginProfiles() = runTest(dispatcher) { for (enabled in listOf(false, true)) { for (user in listOf(current.user.copy(pin = "1234"), current.user.copy(requireLogin = true))) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt index 0b7bb5fe..59e1849b 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/SignedInContentTest.kt @@ -12,7 +12,10 @@ import androidx.lifecycle.Lifecycle import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.every +import io.mockk.mockk import java.io.File import java.util.UUID import kotlinx.coroutines.flow.MutableStateFlow @@ -24,12 +27,14 @@ import org.junit.runner.RunWith import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.TitleFixture import org.justinvisser.wolfskin.tv.TitleTrailer +import org.justinvisser.wolfskin.tv.TrailerReturnClock +import org.justinvisser.wolfskin.tv.TrailerReturnState import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** The signed-in title survives a stopped activity without removing protected-person gates. */ +/** Only the first valid trailer return keeps an auto-off title visible; other returns re-enter setup. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -38,18 +43,30 @@ class SignedInContentTest { private val server = JellyfinServer(UUID.randomUUID(), "Household", "https://jellyfin.test", null) private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = "test-token") private val liveSession = MutableStateFlow(CurrentUser(server, user)) + private var elapsed = 1_000L + private var boot = 7 + private val clock = mockk().also { + every { it.readElapsedMillis() } answers { elapsed } + every { it.readBootCount() } answers { boot } + } + private val trailerReturn = TrailerReturnState(clock) + private lateinit var destination: Destination - private fun mount(person: JellyfinUser = user) { + private fun mount(person: JellyfinUser = user, automatic: Boolean = false) { liveSession.value = CurrentUser(server, person) val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) val data = fixture.title("Locke", "justin").copy(trailerUrl = "https://www.youtube.com/watch?v=ab_-defghij") + destination = Destination.Wolfskin("title/${data.item.id}") val shell = ShellState(fixture.people, "justin").apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/${data.item.id}") } val title = TitleState(data, shell.person) { action -> - if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) {}) + if (action is TitleAction.Trailer) assertTrue(TitleTrailer(rule.activity).open(action.url) { + trailerReturn.prepare(liveSession.value, destination) + }) } rule.setContent { WolfskinTheme { - SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = false) { + SignedInContent(CurrentUser(server, person), liveSession, signInAutomatically = automatic, + trailerReturn, { destination }) { TitleSurface(title, shell) } } @@ -59,6 +76,8 @@ class SignedInContentTest { private fun stopAndReturn() { rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + // Startup or onResume may clear the marker before the returning composition catches up. + trailerReturn.finishHandoff() rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) rule.waitForIdle() } @@ -78,6 +97,57 @@ class SignedInContentTest { rule.onRoot().captureRoboImage(File(directory, "returned-title.png")) } + @Test fun aSecondOrdinaryReturnAfterTheTrailerHidesTheTitle() { + mount() + trailerReturn.prepare(liveSession.value, destination) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertIsDisplayed() + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun handoffExpiringWhileAwayDoesNotRevealTheTitle() { + mount() + trailerReturn.prepare(liveSession.value, destination) + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + elapsed += 600_000L + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun changedBootWhileAwayDoesNotRevealTheTitle() { + mount() + trailerReturn.prepare(liveSession.value, destination) + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + boot += 1 + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun requireLoginAddedDuringAHandoffKeepsTheTitleHidden() { + mount() + trailerReturn.prepare(liveSession.value, destination) + rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) + liveSession.value = CurrentUser(server, user.copy(requireLogin = true)) + rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) + rule.waitForIdle() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + + @Test fun ordinaryWarmReturnWithAutomaticSignInOnKeepsTheTitleVisible() { + mount(automatic = true) + stopAndReturn() + rule.onNodeWithTag("title-heading").assertIsDisplayed() + } + + @Test fun ordinaryWarmReturnHidesTheTitleWithAutomaticSignInOff() { + mount() + stopAndReturn() + rule.onNodeWithTag("title-heading").assertDoesNotExist() + } + @Test fun stoppedPinProtectedTitleRemainsHiddenUntilThePickerUnlocksIt() { mount(user.copy(pin = "1234")) stopAndReturn() @@ -92,6 +162,7 @@ class SignedInContentTest { @Test fun protectionAddedAfterOpeningTheTitleIsReadAtStopNotFromTheOldEntry() { mount() + trailerReturn.prepare(liveSession.value, destination) liveSession.value = CurrentUser(server, user.copy(pin = "1234")) stopAndReturn() rule.onNodeWithTag("title-heading").assertDoesNotExist() @@ -99,6 +170,7 @@ class SignedInContentTest { @Test fun profileProtectedWhileAwayDoesNotReappearOnReturn() { mount() + trailerReturn.prepare(liveSession.value, destination) rule.activityRule.scenario.moveToState(Lifecycle.State.CREATED) liveSession.value = CurrentUser(server, user.copy(pin = "1234")) rule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index cf2e1df3..81fc0058 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -253,7 +253,7 @@ class NavigationCutTest { every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow(null), + mockk(relaxed = true), screensaver, kotlinx.coroutines.flow.MutableStateFlow(null), mockk(relaxed = true), Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), setupInterceptor = { destination, modifier -> androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt index f647b914..775a63ca 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupNavigationTest.kt @@ -101,7 +101,7 @@ class WolfskinSetupNavigationTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, liveSession = repository.current, modifier = Modifier, + mockk(relaxed = true), screensaver, liveSession = repository.current, trailerReturn = mockk(relaxed = true), modifier = Modifier, setupInterceptor = { destination, modifier -> WolfskinSetupContent(destination, modifier, model) }) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt index b01e145d..3e855e33 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinSetupRollbackTest.kt @@ -104,7 +104,7 @@ class WolfskinSetupRollbackTest { every { screensaver.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) rule.setContent { MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), - mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null)) + mockk(relaxed = true), screensaver, liveSession = MutableStateFlow(null), trailerReturn = mockk(relaxed = true)) } fun awaitHeading(resource: Int) { rule.waitUntil(5_000) { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt index 868871ee..0581b74e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TrailerReturnState.kt @@ -47,6 +47,15 @@ class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClo pending = TrailerReturnRecord(current.server.id.toString(), current.user.id.toString(), route, elapsed, boot) } + /** Snapshot before lifecycle cleanup; asynchronous startup must still recheck identity and expiry. */ + @Synchronized + internal fun pendingHandoff(): TrailerReturnRecord? = pending + + /** A live return has the same identity, title and deadline constraints as its saved task. */ + @Synchronized + internal fun canContinue(record: TrailerReturnRecord?, current: CurrentUser?, destination: Destination?): Boolean = + record != null && matches(record, current) && record.route == titleDestination(destination) && isWithinWindow(record) + /** A task snapshot cannot carry an old identity after a person/protection/destination change. */ @Synchronized fun save(outState: Bundle, current: CurrentUser?, destination: Destination?) { @@ -136,7 +145,7 @@ class TrailerReturnState @Inject constructor(private val clock: TrailerReturnClo } @Serializable -private data class TrailerReturnRecord( +internal data class TrailerReturnRecord( val serverId: String, val userId: String, val route: String, diff --git a/docker/README.md b/docker/README.md index 4ff9ec89..0942bb83 100644 --- a/docker/README.md +++ b/docker/README.md @@ -469,10 +469,13 @@ session. A failed selection offers Wolfskin **Try again** and **Sign in** action hidden/unsaved profiles can use username/password sign-in through the setup picker's Settings gear. An outage never replaces the saved household endpoint. -Returning from an external app such as SmartTube keeps the originating title and -person when the same unprotected identity remains live and its signed-in app -entry is still active, even with automatic sign-in off. Live protection is checked -at stop and again when the stopped entry starts. +With **Sign in automatically** off, ordinary returns from the background show the +profile picker. Only the first return after a trailer handoff keeps the originating +title and person: the same unprotected live identity and active signed-in entry +must match the pending handoff, exact title, same boot and under-ten-minute window. +Live protection is checked at stop and again when the stopped entry starts. The +handoff is cleared on return; a second, ordinary background return shows the picker. +Automatic sign-in on keeps its existing ordinary-return behaviour. If Android kills Wolfskin while the trailer is in front, the same saved Android task can continue for **strictly less than ten minutes from the handoff, on the -- 2.49.1