0.2.99
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
-17
@@ -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
File diff suppressed because it is too large
Load Diff
+8223
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user