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
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.98"
val defaultVersionName = "0.2.99"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -1,6 +1,4 @@
package com.ponzischeme89.memby.ui.components.media
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -8,7 +6,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.ItemRatingsStrip
import com.ponzischeme89.memby.ui.ResumableMediaCard
import com.ponzischeme89.memby.ui.responsiveRowCardWidth
import com.ponzischeme89.memby.ui.toResumableMediaCardModel
@@ -56,19 +53,6 @@ fun ContinueWatchingCard(
onFocused = onFocused,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
titleSupplement = if (item.isMovie) {
{ focused ->
ItemRatingsStrip(
item = item,
load = focused,
modifier = Modifier.padding(top = 2.dp),
reserveSpace = false,
compact = true,
)
}
} else {
null
},
modifier = modifier
)
}
@@ -184,7 +184,9 @@ internal fun MediaRow(
val previousPageFocusRequester = remember { FocusRequester() }
val nextPageFocusRequester = remember { FocusRequester() }
val viewAllFocusRequester = remember { FocusRequester() }
val headerReturnFocusRequester = 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 pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
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.
repeat(6) {
delay(16.milliseconds)
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
currentOnVerticalFocusRequestConsumed(request.requestId)
return@LaunchedEffect
}
@@ -221,7 +223,7 @@ internal fun MediaRow(
// attach it, then keep the transfer bounded so a refreshed row can never trap focus.
repeat(6) {
delay(16.milliseconds)
if (runCatching { pagedCardFocusRequester.requestFocus() }.isSuccess) {
if (pagedCardFocusRequester.requestFocusIfAttached()) {
return@LaunchedEffect
}
}
@@ -237,9 +239,22 @@ internal fun MediaRow(
val pageSize = if (
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
) 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)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = HomeContentHorizontalInset),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HomeContentHorizontalInset)
.focusGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
val visual = homeRowVisual(row)
@@ -257,6 +272,7 @@ internal fun MediaRow(
ViewAllButton(
label = viewAllLabel,
focusRequester = viewAllFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = onViewAll,
)
Spacer(Modifier.width(8.dp))
@@ -265,6 +281,7 @@ internal fun MediaRow(
forward = false,
enabled = canScrollBack,
focusRequester = previousPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
pagedCardIndex = target
@@ -276,6 +293,7 @@ internal fun MediaRow(
forward = true,
enabled = canScrollForward,
focusRequester = nextPageFocusRequester,
downFocusRequester = headerReturnFocusRequester,
onClick = {
val target = (rowState.firstVisibleItemIndex + pageSize)
.coerceAtMost(row.items.lastIndex)
@@ -351,6 +369,9 @@ internal fun MediaRow(
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
}
if (item.id == headerReturnTargetId) {
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
}
if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
}
@@ -362,14 +383,16 @@ internal fun MediaRow(
return@onPreviewKeyEvent false
}
when (event.key) {
Key.DirectionUp -> onMoveVertical(index, RowFocusDirection.UP)
Key.DirectionUp -> {
requestHeaderFocus() ||
onMoveVertical(index, RowFocusDirection.UP)
}
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionLeft -> {
val firstVisible = rowState.layoutInfo.visibleItemsInfo
.firstOrNull()?.index
if (canScrollBack && index == firstVisible) {
runCatching { previousPageFocusRequester.requestFocus() }
.isSuccess
previousPageFocusRequester.requestFocusIfAttached()
} else {
false
}
@@ -380,9 +403,9 @@ internal fun MediaRow(
if (index != lastVisible) {
false
} else if (viewAllLabel != null && onViewAll != null) {
runCatching { viewAllFocusRequester.requestFocus() }.isSuccess
viewAllFocusRequester.requestFocusIfAttached()
} else if (canScrollForward) {
runCatching { nextPageFocusRequester.requestFocus() }.isSuccess
nextPageFocusRequester.requestFocusIfAttached()
} else {
false
}
@@ -391,6 +414,7 @@ internal fun MediaRow(
}
}
val focused: () -> Unit = {
headerReturnItemId = item.id
onContentFocused()
// LazyRow already knows the semantic position. Passing it on
// avoids searching the row again on every D-pad focus move.
@@ -434,6 +458,7 @@ private fun GalleryJumpButton(
forward: Boolean,
enabled: Boolean,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
@@ -445,7 +470,10 @@ private fun GalleryJumpButton(
.width(42.dp)
.height(36.dp)
.focusRequester(focusRequester)
.focusProperties { canFocus = enabled },
.focusProperties {
canFocus = enabled
down = downFocusRequester
},
) { focused ->
Box(
Modifier
@@ -479,6 +507,7 @@ private fun GalleryJumpButton(
private fun ViewAllButton(
label: String,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
onClick: () -> Unit,
) {
FocusScaleContainer(
@@ -487,7 +516,8 @@ private fun ViewAllButton(
contentDescription = label,
modifier = Modifier
.height(36.dp)
.focusRequester(focusRequester),
.focusRequester(focusRequester)
.focusProperties { down = downFocusRequester },
) { focused ->
Row(
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 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
// item id, where that is a crash rather than a repeated poster.
val items = found.distinctItems()
// Gateway payloads carry the backend ranker's score. Preserve that
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
val ranked = if (items.any { it.membyRecommendationScore != null }) {
items
} else {
rankSearchResults(term, items)
}
// Keep the backend's relevance and personalisation order within each
// textual tier, but never let those softer signals bury the title the
// viewer typed exactly. This matters especially in the one-column list,
// where tenth place is ten rows away rather than the second grid row.
val ranked = rankSearchResults(term, items)
cache[term] = ranked
viewModelScope.launch { repository.recordSearch(term) }
_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
fun `gateway scored order can be preserved by callers`() {
fun `an exact title beats a higher gateway personalisation score`() {
val backendOrder = listOf(
BaseItem(id = "personal", name = "Dune Messiah", membyRecommendationScore = 9.0),
BaseItem(id = "exact", name = "Dune", membyRecommendationScore = 8.0),
BaseItem(id = "personal", name = "Black Hawk", membyRecommendationScore = 9.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 = if (backendOrder.any { it.membyRecommendationScore != null }) {
backendOrder
} else {
rankSearchResults("dune", backendOrder)
}
assertEquals(listOf("Dune Messiah", "Dune"), names(displayed))
val displayed = rankSearchResults("black hawk down", backendOrder)
assertEquals(listOf("Black Hawk Down", "Black Hawk", "Hawk the Slayer"), 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