0.2.59 - Settings save fixes
This commit is contained in:
@@ -132,6 +132,27 @@ private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
|
||||
/** The `kind` of the server-composed hero row. It is consumed here, never drawn as a row. */
|
||||
internal const val SERVER_HERO_ROW_KIND = "hero"
|
||||
|
||||
/**
|
||||
* Whether a card is something to lead the launcher with tonight.
|
||||
*
|
||||
* A film somebody has already finished is not: the hero exists to be pressed, and the
|
||||
* answer to "watch this" cannot be a title they watched last week. This holds however the
|
||||
* card was chosen, and regardless of the *Hide films you have seen* preference — that one
|
||||
* is about shelves, and a shelf of a hundred cards can afford to carry one somebody has
|
||||
* seen where a row of four cannot.
|
||||
*
|
||||
* **A series is deliberately left alone.** Being marked watched there means somebody is up
|
||||
* to date, which is exactly who a season premiere is news for — the whole reason the
|
||||
* gateway can lead with a returning show.
|
||||
*
|
||||
* The gateway applies the same rule when it composes the row, so this is the second half of
|
||||
* one decision rather than a disagreement with it: what it catches is the launcher a
|
||||
* television draws from its cache before the first refresh lands, and a gateway older than
|
||||
* the rule.
|
||||
*/
|
||||
internal fun heroWorthLeadingWith(item: BaseItem): Boolean =
|
||||
!(item.isMovie && item.userData?.played == true)
|
||||
|
||||
/**
|
||||
* The hero the gateway composed, if it sent one.
|
||||
*
|
||||
@@ -151,6 +172,7 @@ internal fun serverHeroPicks(rows: List<HomeRow>): List<HomeHeroPick> =
|
||||
.filter { it.kind == SERVER_HERO_ROW_KIND }
|
||||
.flatMap { it.items.asSequence() }
|
||||
.filter { it.membyPlayable }
|
||||
.filter(::heroWorthLeadingWith)
|
||||
.distinctBy(BaseItem::id)
|
||||
.take(4)
|
||||
.map { item ->
|
||||
@@ -191,17 +213,20 @@ internal fun selectHomeHeroMovies(
|
||||
return words.any(label::contains)
|
||||
}
|
||||
|
||||
// Unwatched throughout: see heroWorthLeadingWith. It is applied to each list rather
|
||||
// than to the rows so the rotation still counts in cards that can actually lead.
|
||||
fun List<HomeBrowseRow>.unwatchedMovies() =
|
||||
flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).filter(::heroWorthLeadingWith)
|
||||
|
||||
val newReleases = rows
|
||||
.filter { it.matches("latest", "recent", "new release", "just added") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
.unwatchedMovies()
|
||||
.rotatedBy(day)
|
||||
val popular = rows
|
||||
.filter { it.matches("popular", "trending", "recommended", "top pick") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
.unwatchedMovies()
|
||||
.rotatedBy(day)
|
||||
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).rotatedBy(day)
|
||||
val everyMovie = rows.unwatchedMovies().rotatedBy(day)
|
||||
|
||||
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
|
||||
|
||||
|
||||
@@ -459,11 +459,12 @@ private fun DiscoverPane(
|
||||
QueryLine(state)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
// The keyboard is the pane's entry point and its top-left control, so
|
||||
// Down from the tabs lands on it and Up from it goes back.
|
||||
.focusRequester(paneFocusRequester)
|
||||
.focusProperties { up = tabsFocusRequester },
|
||||
// The keyboard is the pane's entry point, so Down from the tabs lands on it.
|
||||
// Only the *entry* is declared here: an `up` hung off this Box would be
|
||||
// inherited by every key underneath it, which is what made Up anywhere in
|
||||
// the letters leave for the tab strip instead of moving one row up. The
|
||||
// escape belongs to the top row alone, and the keyboard states it per key.
|
||||
Modifier.focusRequester(paneFocusRequester),
|
||||
) {
|
||||
TvKeyboard(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
@@ -476,6 +477,7 @@ private fun DiscoverPane(
|
||||
onCharacter = onAppendToQuery,
|
||||
onBackspace = onBackspace,
|
||||
onClear = onClearQuery,
|
||||
upTarget = tabsFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,16 +267,24 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun dismissNotice() = _state.update { it.copy(notice = null) }
|
||||
|
||||
companion object {
|
||||
const val DEBOUNCE_MS = 300L
|
||||
/**
|
||||
* Longer than the Search tab's, because the thing at the other end is different.
|
||||
* Typing on a remote is around half a second a letter, so a 300 ms window let every
|
||||
* keystroke past the threshold through as its own live Radarr and Sonarr query.
|
||||
*/
|
||||
const val DEBOUNCE_MS = 700L
|
||||
|
||||
/**
|
||||
* Three characters, not the Search tab's two.
|
||||
* Five characters, not the Search tab's two.
|
||||
*
|
||||
* Every keystroke here reaches Radarr and Sonarr rather than a local index, and a
|
||||
* two-letter prefix returns a hundred films nobody meant while costing two live
|
||||
* provider queries. The gateway independently refuses under two.
|
||||
* Every keystroke here reaches Radarr and Sonarr rather than a local index, so a
|
||||
* viewer typing a title on a remote spends a live provider query per letter — and
|
||||
* the early ones are worthless: a three-letter prefix returns a hundred films
|
||||
* nobody meant. Five is roughly where a prefix starts naming something, so the
|
||||
* lookups begin when they can answer rather than as soon as they are legal. The
|
||||
* gateway independently refuses under two.
|
||||
*/
|
||||
const val MIN_QUERY_LENGTH = 3
|
||||
const val MIN_QUERY_LENGTH = 5
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -511,6 +511,14 @@ internal fun TvKeyboard(
|
||||
onCharacter: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
/**
|
||||
* Where Up goes from the **top row of letters**, for a host that has something above the
|
||||
* keyboard — the Requests page's tab strip. It is stated per key rather than declared on
|
||||
* a parent, because `focusProperties` is inherited by every descendant: hung off a Box
|
||||
* around the keyboard it applies to all thirty-nine keys, so Up anywhere in the letters
|
||||
* leaves for the tabs instead of moving one row up.
|
||||
*/
|
||||
upTarget: FocusRequester? = null,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
KeyboardRows.forEachIndexed { rowIndex, row ->
|
||||
@@ -532,6 +540,7 @@ internal fun TvKeyboard(
|
||||
if (columnIndex == KEYBOARD_COLUMNS - 1) {
|
||||
right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
|
||||
}
|
||||
if (rowIndex == 0) upTarget?.let { up = it }
|
||||
}
|
||||
.then(
|
||||
if (index == 0) Modifier.focusRequester(keyboardEntry) else Modifier,
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.ponzischeme89.memby.data.localEpochDay
|
||||
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -259,6 +260,58 @@ class HomeMovieHeroTest {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Watched films -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The hero is four cards answering "watch this tonight". A film already finished is
|
||||
* not an answer to that, however new it is or however well it was reviewed.
|
||||
*/
|
||||
@Test
|
||||
fun `a film already watched never leads the launcher`() {
|
||||
val rows = listOf(
|
||||
HomeBrowseRow(
|
||||
id = "latest-movies",
|
||||
title = "Recently Added Movies",
|
||||
items = listOf(
|
||||
movie("seen", watched = true),
|
||||
movie("partway", positionTicks = 6_000_000_000L),
|
||||
movie("unseen"),
|
||||
),
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("partway", "unseen"),
|
||||
selectHomeHeroMovies(rows).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway applies the same rule, so this is the launcher a television draws from
|
||||
* its cache before the first refresh — and any gateway older than the rule.
|
||||
*/
|
||||
@Test
|
||||
fun `a watched film is dropped from the row the gateway sent`() {
|
||||
val row = heroRow(
|
||||
movie("seen", watched = true).copy(membyHeroLabel = "NEW RELEASE"),
|
||||
movie("unseen").copy(membyHeroLabel = "NEW RELEASE"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("unseen"), serverHeroPicks(listOf(row)).map { it.item.id })
|
||||
}
|
||||
|
||||
/** A show marked watched is somebody up to date — exactly who a new season is for. */
|
||||
@Test
|
||||
fun `a watched series still leads`() {
|
||||
val row = heroRow(
|
||||
movie("show", watched = true).copy(type = "Series", membyHeroLabel = "NEW SEASON"),
|
||||
)
|
||||
|
||||
assertEquals(listOf("show"), serverHeroPicks(listOf(row)).map { it.item.id })
|
||||
}
|
||||
|
||||
// --- Midnight ----------------------------------------------------------------
|
||||
|
||||
/** Local, not UTC: the day must turn over at the viewer's midnight. */
|
||||
@@ -308,6 +361,17 @@ class HomeMovieHeroTest {
|
||||
const val DAY_MS = 24L * HOUR_MS
|
||||
}
|
||||
|
||||
private fun movie(
|
||||
id: String,
|
||||
watched: Boolean = false,
|
||||
positionTicks: Long = 0L,
|
||||
) = BaseItem(
|
||||
id = id,
|
||||
name = id,
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = watched, playbackPositionTicks = positionTicks),
|
||||
)
|
||||
|
||||
private fun heroItem(id: String, label: String?, reason: String?) = BaseItem(
|
||||
id = id,
|
||||
name = id,
|
||||
|
||||
@@ -95,9 +95,10 @@ class RequestPresentationTest {
|
||||
fun `the lookup threshold is stricter than the library search's`() {
|
||||
// Every keystroke here reaches Radarr and Sonarr rather than a local index.
|
||||
assertFalse(shouldLookup("du"))
|
||||
assertTrue(shouldLookup("dun"))
|
||||
assertFalse(shouldLookup("dune"))
|
||||
assertTrue(shouldLookup("dunes"))
|
||||
assertFalse(shouldLookup(" a "))
|
||||
assertTrue(shouldLookup(" dune "))
|
||||
assertTrue(shouldLookup(" dune part two "))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user