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
|
// 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
-17
@@ -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
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