This commit is contained in:
ponzischeme89
2026-08-22 07:15:03 +12:00
parent 4fd690a127
commit 5df4a1aaa3
9 changed files with 64672 additions and 45 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -64,7 +64,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.98" val defaultVersionName = "0.2.99"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -1,6 +1,4 @@
package com.ponzischeme89.memby.ui.components.media package com.ponzischeme89.memby.ui.components.media
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -8,7 +6,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.ItemRatingsStrip
import com.ponzischeme89.memby.ui.ResumableMediaCard import com.ponzischeme89.memby.ui.ResumableMediaCard
import com.ponzischeme89.memby.ui.responsiveRowCardWidth import com.ponzischeme89.memby.ui.responsiveRowCardWidth
import com.ponzischeme89.memby.ui.toResumableMediaCardModel import com.ponzischeme89.memby.ui.toResumableMediaCardModel
@@ -56,19 +53,6 @@ fun ContinueWatchingCard(
onFocused = onFocused, onFocused = onFocused,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = modifier, modifier = modifier
titleSupplement = if (item.isMovie) {
{ focused ->
ItemRatingsStrip(
item = item,
load = focused,
modifier = Modifier.padding(top = 2.dp),
reserveSpace = false,
compact = true,
)
}
} else {
null
},
) )
} }
@@ -184,7 +184,9 @@ internal fun MediaRow(
val previousPageFocusRequester = remember { FocusRequester() } val previousPageFocusRequester = remember { FocusRequester() }
val nextPageFocusRequester = remember { FocusRequester() } val nextPageFocusRequester = remember { FocusRequester() }
val viewAllFocusRequester = remember { FocusRequester() } val viewAllFocusRequester = remember { FocusRequester() }
val headerReturnFocusRequester = remember { FocusRequester() }
val pagedCardFocusRequester = remember { FocusRequester() } val pagedCardFocusRequester = remember { FocusRequester() }
var headerReturnItemId by remember(row.id) { mutableStateOf<String?>(null) }
var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) } var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) }
var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) } var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
val requestedEntryIndex = verticalFocusRequest val requestedEntryIndex = verticalFocusRequest
@@ -207,7 +209,7 @@ internal fun MediaRow(
// first time — the frame budget on a Chromecast is not the one a warm row gets. // first time — the frame budget on a Chromecast is not the one a warm row gets.
repeat(6) { repeat(6) {
delay(16.milliseconds) delay(16.milliseconds)
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { if (verticalEntryFocusRequester.requestFocusIfAttached()) {
currentOnVerticalFocusRequestConsumed(request.requestId) currentOnVerticalFocusRequestConsumed(request.requestId)
return@LaunchedEffect return@LaunchedEffect
} }
@@ -221,7 +223,7 @@ internal fun MediaRow(
// attach it, then keep the transfer bounded so a refreshed row can never trap focus. // attach it, then keep the transfer bounded so a refreshed row can never trap focus.
repeat(6) { repeat(6) {
delay(16.milliseconds) delay(16.milliseconds)
if (runCatching { pagedCardFocusRequester.requestFocus() }.isSuccess) { if (pagedCardFocusRequester.requestFocusIfAttached()) {
return@LaunchedEffect return@LaunchedEffect
} }
} }
@@ -237,9 +239,22 @@ internal fun MediaRow(
val pageSize = if ( val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
) 6 else 4 ) 6 else 4
val hasViewAll = viewAllLabel != null && onViewAll != null
val headerReturnTargetId = headerReturnItemId
?.takeIf { focusedId -> row.items.any { it.id == focusedId } }
?: row.items.firstOrNull()?.id
fun requestHeaderFocus(): Boolean = when {
hasViewAll -> viewAllFocusRequester.requestFocusIfAttached()
canScrollBack -> previousPageFocusRequester.requestFocusIfAttached()
canScrollForward -> nextPageFocusRequester.requestFocusIfAttached()
else -> false
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = HomeContentHorizontalInset), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HomeContentHorizontalInset)
.focusGroup(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val visual = homeRowVisual(row) val visual = homeRowVisual(row)
@@ -257,6 +272,7 @@ internal fun MediaRow(
ViewAllButton( ViewAllButton(
label = viewAllLabel, label = viewAllLabel,
focusRequester = viewAllFocusRequester, focusRequester = viewAllFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = onViewAll, onClick = onViewAll,
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@@ -265,6 +281,7 @@ internal fun MediaRow(
forward = false, forward = false,
enabled = canScrollBack, enabled = canScrollBack,
focusRequester = previousPageFocusRequester, focusRequester = previousPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = { onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0) val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
pagedCardIndex = target pagedCardIndex = target
@@ -276,6 +293,7 @@ internal fun MediaRow(
forward = true, forward = true,
enabled = canScrollForward, enabled = canScrollForward,
focusRequester = nextPageFocusRequester, focusRequester = nextPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = { onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize) val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex) .coerceAtMost(row.items.lastIndex)
@@ -351,6 +369,9 @@ internal fun MediaRow(
if (item.id == returnFocusItemId) { if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester) cardModifier = cardModifier.focusRequester(returnFocusRequester)
} }
if (item.id == headerReturnTargetId) {
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
}
if (index == requestedEntryIndex) { if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester) cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
} }
@@ -362,14 +383,16 @@ internal fun MediaRow(
return@onPreviewKeyEvent false return@onPreviewKeyEvent false
} }
when (event.key) { when (event.key) {
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP) Key.DirectionUp -> {
requestHeaderFocus() ||
onMoveVertical(index, RowFocusDirection.UP)
}
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN) Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionLeft -> { Key.DirectionLeft -> {
val firstVisible = rowState.layoutInfo.visibleItemsInfo val firstVisible = rowState.layoutInfo.visibleItemsInfo
.firstOrNull()?.index .firstOrNull()?.index
if (canScrollBack && index == firstVisible) { if (canScrollBack && index == firstVisible) {
runCatching { previousPageFocusRequester.requestFocus() } previousPageFocusRequester.requestFocusIfAttached()
.isSuccess
} else { } else {
false false
} }
@@ -380,9 +403,9 @@ internal fun MediaRow(
if (index != lastVisible) { if (index != lastVisible) {
false false
} else if (viewAllLabel != null && onViewAll != null) { } else if (viewAllLabel != null && onViewAll != null) {
runCatching { viewAllFocusRequester.requestFocus() }.isSuccess viewAllFocusRequester.requestFocusIfAttached()
} else if (canScrollForward) { } else if (canScrollForward) {
runCatching { nextPageFocusRequester.requestFocus() }.isSuccess nextPageFocusRequester.requestFocusIfAttached()
} else { } else {
false false
} }
@@ -391,6 +414,7 @@ internal fun MediaRow(
} }
} }
val focused: () -> Unit = { val focused: () -> Unit = {
headerReturnItemId = item.id
onContentFocused() onContentFocused()
// LazyRow already knows the semantic position. Passing it on // LazyRow already knows the semantic position. Passing it on
// avoids searching the row again on every D-pad focus move. // avoids searching the row again on every D-pad focus move.
@@ -434,6 +458,7 @@ private fun GalleryJumpButton(
forward: Boolean, forward: Boolean,
enabled: Boolean, enabled: Boolean,
focusRequester: FocusRequester, focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
FocusScaleContainer( FocusScaleContainer(
@@ -445,7 +470,10 @@ private fun GalleryJumpButton(
.width(42.dp) .width(42.dp)
.height(36.dp) .height(36.dp)
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusProperties { canFocus = enabled }, .focusProperties {
canFocus = enabled
down = downFocusRequester
},
) { focused -> ) { focused ->
Box( Box(
Modifier Modifier
@@ -479,6 +507,7 @@ private fun GalleryJumpButton(
private fun ViewAllButton( private fun ViewAllButton(
label: String, label: String,
focusRequester: FocusRequester, focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
FocusScaleContainer( FocusScaleContainer(
@@ -487,7 +516,8 @@ private fun ViewAllButton(
contentDescription = label, contentDescription = label,
modifier = Modifier modifier = Modifier
.height(36.dp) .height(36.dp)
.focusRequester(focusRequester), .focusRequester(focusRequester)
.focusProperties { down = downFocusRequester },
) { focused -> ) { focused ->
Row( Row(
modifier = Modifier modifier = Modifier
@@ -522,6 +552,9 @@ private fun ViewAllButton(
} }
} }
private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false)
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
private fun cardFormat( private fun cardFormat(
@@ -438,13 +438,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// both routes is a shape this search genuinely has. The result list is keyed by // both routes is a shape this search genuinely has. The result list is keyed by
// item id, where that is a crash rather than a repeated poster. // item id, where that is a crash rather than a repeated poster.
val items = found.distinctItems() val items = found.distinctItems()
// Gateway payloads carry the backend ranker's score. Preserve that // Keep the backend's relevance and personalisation order within each
// ordering exactly; direct-to-Emby mode keeps the local textual fallback. // textual tier, but never let those softer signals bury the title the
val ranked = if (items.any { it.membyRecommendationScore != null }) { // viewer typed exactly. This matters especially in the one-column list,
items // where tenth place is ten rows away rather than the second grid row.
} else { val ranked = rankSearchResults(term, items)
rankSearchResults(term, items)
}
cache[term] = ranked cache[term] = ranked
viewModelScope.launch { repository.recordSearch(term) } viewModelScope.launch { repository.recordSearch(term) }
_state.update { _state.update {
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.unit.dp
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
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
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
class HomeRowFocusTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
@Test
fun `row action returns focus to the card that opened it`() {
compose.setContent {
PreviewSurface {
MediaRow(
row = HomeBrowseRow(
id = "latest-movies",
title = "Recent releases",
items = listOf(
BaseItem(id = "first", name = "First film", type = "Movie"),
BaseItem(id = "second", name = "Second film", type = "Movie"),
),
kind = MediaRowKind.MOVIES,
emptyMessage = "No films",
),
availableWidth = 960.dp,
contentEntryFocusRequester = null,
returnFocusItemId = null,
returnFocusRequester = remember { FocusRequester() },
verticalFocusRequest = null,
onVerticalFocusRequestConsumed = {},
onMoveVertical = { _, _ -> false },
onContentFocused = {},
onItemFocused = { _, _ -> },
onItemSelected = {},
onItemLongPressed = {},
viewAllLabel = "View All",
onViewAll = {},
modifier = Modifier.fillMaxWidth(),
)
}
}
compose.waitForIdle()
val secondCard = compose.onNodeWithContentDescription("Second film", substring = true)
secondCard.requestFocus()
compose.waitForIdle()
secondCard.assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
compose.waitForIdle()
compose.onNodeWithContentDescription("View All").assertIsFocused()
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
compose.waitForIdle()
secondCard.assertIsFocused()
}
}
@@ -74,17 +74,14 @@ class SearchRankingTest {
} }
@Test @Test
fun `gateway scored order can be preserved by callers`() { fun `an exact title beats a higher gateway personalisation score`() {
val backendOrder = listOf( val backendOrder = listOf(
BaseItem(id = "personal", name = "Dune Messiah", membyRecommendationScore = 9.0), BaseItem(id = "personal", name = "Black Hawk", membyRecommendationScore = 9.0),
BaseItem(id = "exact", name = "Dune", membyRecommendationScore = 8.0), BaseItem(id = "related", name = "Hawk the Slayer", membyRecommendationScore = 8.5),
BaseItem(id = "exact", name = "Black Hawk Down", membyRecommendationScore = 8.0),
) )
// Scored gateway results are intentionally not passed through rankSearchResults. val displayed = rankSearchResults("black hawk down", backendOrder)
val displayed = if (backendOrder.any { it.membyRecommendationScore != null }) {
backendOrder assertEquals(listOf("Black Hawk Down", "Black Hawk", "Hawk the Slayer"), names(displayed))
} else {
rankSearchResults("dune", backendOrder)
}
assertEquals(listOf("Dune Messiah", "Dune"), names(displayed))
} }
} }
+50873
View File
File diff suppressed because it is too large Load Diff
+8223
View File
File diff suppressed because it is too large Load Diff