This commit is contained in:
ponzischeme89
2026-08-19 14:25:44 +12:00
parent 2b43b9ef12
commit 590e069366
83 changed files with 8948 additions and 1266 deletions
+1 -1
View File
@@ -63,7 +63,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.77"
val defaultVersionName = "0.2.78"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -1597,6 +1597,42 @@ class EmbyRepository internal constructor(
return result.played
}
/**
* Marks a whole show watched, or not, and re-reads its episodes.
*
* This is deliberately not [setPlayed] with a series id, even though the call Emby
* receives is the same one. Emby applies a series' played flag to **every episode
* underneath it**, so what the call actually changes is the episode list — and the copy
* this session is holding of that list would otherwise go on claiming the show was half
* watched until its TTL ran out, on the very page the viewer just pressed the button on.
*
* The episodes' own resume ledger entries go with it: [localResume] takes the *greatest*
* of the card's position and its own record, so a playhead left there would put somebody
* back into the middle of an episode Emby has just been told they finished.
*
* Returns the re-read episodes, or null when they could not be re-read — the difference
* matters to the caller, which has an optimistic list on screen and must keep it rather
* than replace it with nothing.
*/
suspend fun setSeriesPlayed(seriesId: String, played: Boolean): List<BaseItem>? {
setPlayed(seriesId, played)
forgetSeriesEpisodes(seriesId)
return runCatching { getSeriesEpisodes(seriesId) }.getOrNull()
}
/**
* Drops one show's cached episodes, and the resume ledger entries belonging to them, so
* the next read is the server's own answer.
*/
private suspend fun forgetSeriesEpisodes(seriesId: String) {
val cached = seriesEpisodesMutex.withLock {
val entry = seriesEpisodesCache.remove(seriesId)
seriesEpisodesInFlight.remove(seriesId)?.cancel()
entry?.episodes
}
cached?.forEach { forgetLocalResume(it.id) }
}
/** Removes a title from Continue Watching without changing its watched state. */
suspend fun removeFromContinueWatching(itemId: String) {
// Taking a title off the shelf is a statement that its playhead no longer matters.
@@ -616,6 +616,23 @@ data class GatewayMediaRequestItem(
val status: String = "",
val statusLabel: String = "",
val statusDetail: String = "",
/**
* Whole percent, and present only on a downloading card.
*
* The gateway omits it whenever nothing could measure one, so zero means "no figure"
* rather than "no bytes yet" — see [requestProgressFraction], which reads it that way.
* A build talking to a gateway that predates the download states never receives one.
*/
val progress: Int = 0,
/**
* How long the download client says the bytes will take, or zero when it would not say.
*
* That zero is load-bearing: it is the difference between the card promising a time and
* the card saying plainly that we will let them know. The sentence is already composed
* in [statusDetail] — this is here so a later screen can word it differently without
* the two disagreeing about whether there is an estimate at all.
*/
val estimatedReadySeconds: Int = 0,
/** Set once Emby has imported it, so an arrived request can open its own detail page. */
val embyItemId: String = "",
)
@@ -184,6 +184,12 @@ internal val MEMBY_CAPABILITIES = listOf(
// whose audio it will bitstream. A gateway seeing this knows the audio tokens beside
// it are a complete answer rather than an older app that simply never described itself.
"audio_passthrough_v1",
// Declares that this build understands the download states on a request card —
// searching, found, downloading, processing, failed — and has somewhere to draw a
// percentage and an estimate. A gateway seeing no such token narrows all four back to
// "processing", the single word the whole span used to be, so an older television reads
// its request page exactly as it always did.
"request_progress_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -916,7 +916,7 @@ private val SynopsisLineHeight = 20.sp
* A single measure pass, in the layout phase. No subcomposition, no measuring twice, and
* nothing read in composition: the same rule the collapsing hero band follows.
*/
private fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints ->
internal fun Modifier.wholeLines(lineHeight: TextUnit): Modifier = layout { measurable, constraints ->
val line = lineHeight.roundToPx().coerceAtLeast(1)
val placeable = measurable.measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
@@ -526,6 +526,48 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* The launcher's half of marking a whole show watched, or not.
*
* The network call belongs to the series page — see [EmbyRepository.setSeriesPlayed],
* which has to re-read the episode list the page is drawing. What is left here is the
* launcher, and the launcher does not hold the show: Continue Watching and Next Up hold
* its **episodes**, so flipping the series' own user data would leave the card the
* viewer was trying to be rid of sitting exactly where it was until the next refresh.
* The same call rolls the change back, which is why the pruning is not conditional on
* anything but [played].
*/
fun applySeriesPlayed(series: BaseItem, played: Boolean) {
_playedChanges.update { it + (series.id to played) }
updateUserData(series.id) {
it.copy(played = played, unplayedItemCount = if (played) 0 else it.unplayedItemCount)
}
if (!played) return
_state.update { state ->
fun List<BaseItem>.withoutSeries() = filterNot { it.seriesId == series.id }
state.copy(
continueWatching = state.continueWatching.withoutSeries(),
rows = state.rows.map { row ->
if (row.kind == "continue" || row.kind == "nextup") {
row.copy(items = row.items.withoutSeries())
} else {
row
}
},
)
}
}
/**
* Re-reads Continue Watching once the series page's own mutation has settled, so the
* optimistic pruning above is replaced by whatever the server actually thinks. A show
* with a new episode already imported comes straight back, which is the case that makes
* the refresh worth making rather than trusting the optimism.
*/
fun refreshContinueWatching() {
viewModelScope.launch { refreshWatching() }
}
fun removeFromContinueWatching(item: BaseItem) {
val previous = _state.value
_state.update { state ->
@@ -146,7 +146,6 @@ import com.ponzischeme89.memby.ui.calendar.CalendarScreen
import com.ponzischeme89.memby.ui.requests.RequestsScreen
import com.ponzischeme89.memby.ui.requests.RequestsViewModel
import com.ponzischeme89.memby.ui.requests.RequestsViewModelFactory
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity
import com.ponzischeme89.memby.ui.player.PrerollPreloader
@@ -1917,8 +1916,6 @@ private fun HomeScreen(
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var removingProfileId by remember { mutableStateOf<String?>(null) }
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) }
var navigationExpanded by rememberSaveable { mutableStateOf(false) }
var restoreRailAfterSettings by remember { mutableStateOf(false) }
var detailsItem by remember { mutableStateOf<BaseItem?>(null) }
@@ -2050,14 +2047,9 @@ private fun HomeScreen(
// moved to Home, the stance the calendar takes: the rail entry goes with the
// feature, so leaving it there would strand the viewer on a page nothing can
// navigate back to.
val strandedOnDestination = selectedDestination == BrowseDestination.GENRES
if (genreBrowseItemType != null || strandedOnDestination) {
genreBrowseItemType = null
genreBrowseInitialCategoryId = null
if (strandedOnDestination) {
selectedDestination = BrowseDestination.HOME
railFocusDestination = BrowseDestination.HOME
}
if (selectedDestination == BrowseDestination.GENRES) {
selectedDestination = BrowseDestination.HOME
railFocusDestination = BrowseDestination.HOME
kotlinx.coroutines.delay(16L)
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
}
@@ -2470,7 +2462,6 @@ private fun HomeScreen(
userSwitcherVisible || showProfiles -> "profiles"
detailsItem != null -> "details"
selectedMyShow != null -> "my_show_details"
genreBrowseItemType != null -> "genre_browser"
else -> selectedDestination.name.lowercase()
}
LaunchedEffect(journeyScreen) {
@@ -2547,7 +2538,6 @@ private fun HomeScreen(
screen = journeyScreen, feature = destination.name.lowercase(),
source = journeyScreen, target = destination.name.lowercase(),
)
genreBrowseItemType = null
when (destination) {
BrowseDestination.SETTINGS -> {
railFocusDestination = BrowseDestination.SETTINGS
@@ -2737,9 +2727,9 @@ private fun HomeScreen(
}
// The rail's own Genres destination browses the catalogue, films and shows
// together — a household browses "Comedy", not "comedy films". The Movies
// and TV Series pages open the same screen through genreBrowseItemType
// below, naming a type so neither of those grids can cross media types.
// together — a household browses "Comedy", not "comedy films". It is the
// one way in: the Movies and TV Series pages carry no genre row of their
// own, so neither of those grids can cross media types.
if (selectedDestination == BrowseDestination.GENRES) {
GenreBrowseScreen(
itemType = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE,
@@ -2779,44 +2769,6 @@ private fun HomeScreen(
return@BoxWithConstraints
}
genreBrowseItemType?.let { itemType ->
GenreBrowseScreen(
itemType = itemType,
initialCategoryId = genreBrowseInitialCategoryId
?: com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
favouriteStates = favoriteChanges,
playedStates = playedChanges,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
returnFocusRequester = cardReturnFocusRequester,
onItemFocused = homeViewModel::focusItem,
onItemSelected = { item ->
returnRowId = GENRE_BROWSER_ROW_ID
returnRowKind = null
returnItemId = item.id
homeViewModel.focusItem(item)
homeViewModel.trackJourney(
category = "content", action = "open", screen = "genre_browser",
feature = "genre_browse", source = "genre_results", target = "details",
itemName = item.name, itemType = item.type,
)
detailsAiringNotice = null
detailsItem = item
},
onContentFocused = { navigationExpanded = false },
onClose = {
genreBrowseItemType = null
genreBrowseInitialCategoryId = null
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { contentFocusRequester.requestFocus() }
}
},
)
return@BoxWithConstraints
}
FocusedHomeBackdrop(homeViewModel)
val verticalState = verticalStates.getOrPut(selectedDestination.name) {
LazyListState()
@@ -2872,19 +2824,11 @@ private fun HomeScreen(
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
}
// Must agree exactly with the items placed above the rows in the LazyColumn
// below, because it is what turns a row index into a scroll target. The
// genre strip is only one of them while the gateway has that feature on —
// counting it regardless scrolled a row short of the destination on Movies
// and TV Shows, which lands the requested card outside the composed window
// and leaves the move with nothing to complete it.
// below, because it is what turns a row index into a scroll target.
// Counting one that is not placed scrolls a row short of the destination,
// which lands the requested card outside the composed window and leaves
// the move with nothing to complete it.
val leadingItemCount =
(
if (
genreBrowserEnabled &&
(selectedDestination == BrowseDestination.MOVIES ||
selectedDestination == BrowseDestination.SHOWS)
) 1 else 0
) +
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
if (
selectedDestination == BrowseDestination.FAVORITES &&
@@ -2985,30 +2929,6 @@ private fun HomeScreen(
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (
genreBrowserEnabled &&
(selectedDestination == BrowseDestination.MOVIES ||
selectedDestination == BrowseDestination.SHOWS)
) {
item(key = "genre-browser", contentType = "genre-browser") {
val itemType = if (selectedDestination == BrowseDestination.SHOWS) "Series" else "Movie"
GenreDiscoveryStrip(
itemType = itemType,
navigationFocusRequester = navigationFocusRequester,
entryFocusRequester = contentFocusRequester,
onFocused = { navigationExpanded = false },
onOpenCategory = { categoryId ->
homeViewModel.trackJourney(
category = "navigation", action = "open",
screen = selectedDestination.name.lowercase(), feature = "genre_browse",
source = "genre_strip", target = "genre_browser",
)
genreBrowseInitialCategoryId = categoryId
genreBrowseItemType = itemType
},
)
}
}
if (selectedDestination == BrowseDestination.SHOWS) {
item(key = "my-shows", contentType = "my-shows") {
MyShowsStrip(
@@ -3029,12 +2949,8 @@ private fun HomeScreen(
availableWidth = contentWidth,
density = settings.homeCardDensity,
navigationFocusRequester = navigationFocusRequester,
// The genre launcher owns the entry target only while
// the server has enabled it. When it is off, My Shows
// becomes the first reachable content on this page.
contentFocusRequester = contentFocusRequester.takeUnless {
genreBrowserEnabled
},
// My Shows is the first reachable content on this page.
contentFocusRequester = contentFocusRequester,
returnFocusItemId = myShowReturnItemId,
returnFocusRequester = myShowReturnFocusRequester,
onShowSelected = {
@@ -3111,9 +3027,7 @@ private fun HomeScreen(
contentEntryFocusRequester = contentFocusRequester.takeIf {
!hasContextualHero &&
(selectedDestination != BrowseDestination.SHOWS ||
(!genreBrowserEnabled && myShows.isEmpty())) &&
(selectedDestination != BrowseDestination.MOVIES ||
!genreBrowserEnabled) &&
myShows.isEmpty()) &&
row.id == firstPopulatedRowId
},
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
@@ -3682,6 +3596,19 @@ private fun HomeScreen(
factory = RequestsViewModelFactory(repo),
)
val requestsState by requestsViewModel.state.collectAsStateWithLifecycle()
// The page keeps itself current while it is on screen — it is the one screen
// whose cards move without anybody touching anything. The loop is hung off the
// composition rather than off the view model, which is keyed on the profile and
// outlives this block, and off STARTED rather than run unconditionally, so a
// television left on the launcher or switched to another app stops asking
// entirely. Whether there is anything worth asking about is the view model's
// own judgement — see pollWhileVisible.
val requestsLifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
LaunchedEffect(requestsViewModel, requestsLifecycleOwner) {
requestsLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
requestsViewModel.pollWhileVisible()
}
}
// The page covers the launcher, rail and all, so it carries its own rather than
// leaving Left pointing at one nobody can see. Its own FocusRequester, because
// the launcher's is still attached to the rail underneath and one requester on
@@ -3710,7 +3637,6 @@ private fun HomeScreen(
onDestinationSelected = { destination ->
requestsRailExpanded = false
showRequests = false
genreBrowseItemType = null
when (destination) {
BrowseDestination.SETTINGS -> {
railFocusDestination = BrowseDestination.SETTINGS
@@ -4428,6 +4354,15 @@ private fun FocusedDetailsOverlay(
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow,
onSeriesPlayedChanged = { series, played ->
homeViewModel.trackJourney(
category = "library", action = if (played) "mark_played" else "mark_unplayed",
screen = "details", feature = "played_status", itemName = series.name,
itemType = series.type, outcome = "success",
)
homeViewModel.applySeriesPlayed(series, played)
},
onSeriesPlayedSettled = homeViewModel::refreshContinueWatching,
onClose = onClose,
onOpenItem = onOpenItem,
restorePosition = restorePosition,
@@ -23,12 +23,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
@@ -133,19 +136,28 @@ internal fun RadarrMovieDetailContent(
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = RadarrPageGutter, vertical = 46.dp),
.padding(horizontal = RadarrPageGutter, vertical = RadarrPageMargin),
verticalAlignment = Alignment.CenterVertically,
) {
RadarrPoster(poster, title)
Spacer(Modifier.width(38.dp))
// Identity, then the supporting material, then the actions — and the actions
// are measured *before* the supporting material rather than after it. A Column
// hands each unweighted child only what the ones above it left over, so with
// the button row last in the flow a long description simply took its height:
// it rendered as a squeezed sliver rather than disappearing, which is why it
// read as a clipped button rather than as a page that had run out of room.
// Everything that can honestly give way is inside the one weighted child, so
// the give is taken from prose and secondary facts instead. This is the same
// inversion the home hero and the detail hero already make.
Column(Modifier.weight(1f)) {
RadarrStatusRow(detail)
Spacer(Modifier.height(14.dp))
Text(
text = title,
color = Color.White,
fontSize = 40.sp,
lineHeight = 44.sp,
fontSize = 36.sp,
lineHeight = 40.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -158,63 +170,8 @@ internal fun RadarrMovieDetailContent(
Spacer(Modifier.height(10.dp))
DetailFactRow(facts)
}
detail?.genres?.filter(String::isNotBlank)?.takeIf(List<String>::isNotEmpty)
?.let { genres ->
Spacer(Modifier.height(8.dp))
Text(
text = genres.take(4).joinToString(ValueSeparator),
color = MembyMutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings ->
Spacer(Modifier.height(12.dp))
RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f))
}
Spacer(Modifier.height(16.dp))
RadarrReleaseBand(detail)
val overview = detail?.overview?.takeIf(String::isNotBlank)
?: card.overview?.takeIf(String::isNotBlank)
if (overview != null) {
Spacer(Modifier.height(16.dp))
Text(
text = overview,
color = MembyMutedText,
fontSize = 15.sp,
lineHeight = 22.sp,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(0.86f),
)
}
detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates ->
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
dates.forEach { date ->
Column {
Text(
text = date.label.uppercase(),
color = MembyQuietText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
text = date.value,
color = MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
}
Spacer(Modifier.height(26.dp))
RadarrSupporting(card, detail, Modifier.weight(1f, fill = false))
Spacer(Modifier.height(22.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -241,11 +198,144 @@ internal fun RadarrMovieDetailContent(
}
}
/**
* Everything between the fact line and the actions, in one block handed whatever the
* identity above it and the buttons below it left over.
*
* The order the eye reads is the order these are declared; the order they *give way* is the
* order they are measured, which is not the same thing. The single-purpose blocks are
* unweighted, so each is measured first and offered what its predecessors left and each
* drops out whole ([dropIfCramped]) rather than being cut through the middle, because half
* a ratings strip reads as a rendering fault where a missing one reads as a film with no
* scores. The description is the one thing that can honestly be four lines, or two, or
* none, so it is the weighted child: measured last, from what is left, a whole line at a
* time. That is also what reserves the release dates, which are the answer to the question
* this page exists for and must outlive the fourth line of a synopsis.
*/
@Composable
private fun RadarrSupporting(
card: BaseItem,
detail: RadarrMovieDetail?,
modifier: Modifier = Modifier,
) {
Column(modifier.clipToBounds()) {
detail?.genres?.filter(String::isNotBlank)?.takeIf(List<String>::isNotEmpty)
?.let { genres ->
Column(Modifier.dropIfCramped()) {
Spacer(Modifier.height(8.dp))
Text(
text = genres.take(4).joinToString(ValueSeparator),
color = MembyMutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
detail?.ratings?.takeIf(List<*>::isNotEmpty)?.let { ratings ->
Column(Modifier.dropIfCramped()) {
Spacer(Modifier.height(12.dp))
RatingsStrip(ratings, visible = true, modifier = Modifier.fillMaxWidth(0.8f))
}
}
Column(Modifier.dropIfCramped()) {
Spacer(Modifier.height(16.dp))
RadarrReleaseBand(detail)
}
val overview = detail?.overview?.takeIf(String::isNotBlank)
?: card.overview?.takeIf(String::isNotBlank)
if (overview != null) {
Column(Modifier.weight(1f, fill = false)) {
Spacer(Modifier.height(16.dp))
Text(
text = overview,
color = MembyMutedText,
fontSize = 15.sp,
lineHeight = RadarrOverviewLineHeight,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth(0.86f)
// The snap is on the text itself, not on the column around it: a
// wrapper carrying the gap above the prose is a whole number of
// lines *plus 16dp*, which lands the clip through the middle of a
// line — the fault this exists to prevent. The clip sits outside
// it, so it cuts to the snapped height rather than to the text's
// own; a Text handed less room still draws every line it was asked
// for, and without this the dropped ones painted over the dates.
.clipToBounds()
.wholeLines(RadarrOverviewLineHeight),
)
}
}
detail?.releaseDates?.takeIf(List<*>::isNotEmpty)?.let { dates ->
Column(Modifier.dropIfCramped()) {
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
dates.forEach { date ->
Column {
Text(
text = date.label.uppercase(),
color = MembyQuietText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
text = date.value,
color = MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
}
}
}
}
}
}
/**
* Takes the child's natural height when there is room for all of it, and no height at all
* when there is not.
*
* A Column measures each unweighted child against what its predecessors left over, so this
* is the difference between a block that is *omitted* under pressure and one drawn with its
* bottom half missing. Omitting is the honest answer for a single-purpose block the
* genres, the scores, the release dates where every line is either there or it is not.
* Prose gives way by degrees instead, which is [wholeLines].
*
* One measure pass, in the layout phase, with nothing read in composition.
*/
private fun Modifier.dropIfCramped(): Modifier = layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
)
val fits = !constraints.hasBoundedHeight || placeable.height <= constraints.maxHeight
if (fits) {
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
} else {
layout(0, 0) {}
}
}
/** The gutter is the detail pages'; this page sits in the same column as the others. */
private val RadarrPageGutter = DetailSideGutter
private val RadarrPosterWidth = 236.dp
/**
* The page's top and bottom margin. A 1080p television is 540dp tall, so every dp spent
* here is one the description or the release dates cannot have.
*/
private val RadarrPageMargin = 30.dp
/** The description's line box, shared by the text style and by [wholeLines]. */
private val RadarrOverviewLineHeight = 21.sp
@Composable
private fun RadarrPoster(url: String?, title: String) {
val shape = RoundedCornerShape(MembyCardCorner)
@@ -34,6 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -61,6 +62,7 @@ import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.estimateSeriesPace
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
@@ -89,6 +91,7 @@ import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.launch
import java.util.TimeZone
/**
@@ -104,6 +107,14 @@ fun SeriesDetailsOverlay(
onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit,
/**
* The launcher's half of a watched change see [HomeViewModel.applySeriesPlayed]. It is
* called with the change and again with its opposite if the server refuses, because the
* card this page is trying to be rid of is on the launcher rather than here.
*/
onSeriesPlayedChanged: (BaseItem, Boolean) -> Unit = { _, _ -> },
/** Called once the change has settled, so Continue Watching can be re-read. */
onSeriesPlayedSettled: () -> Unit = {},
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
restorePosition: Boolean = false,
@@ -155,6 +166,35 @@ fun SeriesDetailsOverlay(
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
// The show's own watched button. The network call is the page's rather than the
// launcher's because what it changes is the episode list on screen here: Emby applies a
// series' played flag to every episode underneath it, so the list is mutated first and
// replaced by the re-read the repository returns — or put back, if the call failed.
val scope = rememberCoroutineScope()
var markingWatched by remember(item.id) { mutableStateOf(false) }
val setSeriesPlayed: (Boolean) -> Unit = { played ->
if (!markingWatched) {
markingWatched = true
val previous = episodes
episodes = previous?.map { it.withPlayed(played) }
onSeriesPlayedChanged(item, played)
scope.launch {
runCatching { repository.setSeriesPlayed(item.id, played) }
.onSuccess { reread ->
// Null means the re-read itself failed while the change went
// through: the optimistic list is still the better answer.
reread?.let { episodes = it.sortedWith(seriesEpisodeComparator) }
onSeriesPlayedSettled()
}
.onFailure {
episodes = previous
onSeriesPlayedChanged(item, !played)
}
markingWatched = false
}
}
}
SeriesDetailContent(
item = item,
episodes = episodes,
@@ -164,6 +204,7 @@ fun SeriesDetailsOverlay(
onToggleFavorite = onToggleFavorite,
isMyShow = isMyShow,
onToggleMyShow = onToggleMyShow,
onSetSeriesPlayed = setSeriesPlayed,
related = related,
trailer = trailer,
extras = extras,
@@ -188,6 +229,8 @@ internal fun SeriesDetailContent(
onToggleFavorite: (BaseItem, Boolean) -> Unit,
isMyShow: Boolean,
onToggleMyShow: (BaseItem, Boolean) -> Unit,
/** Marks the whole show watched, or unwatched. Inert where nothing can apply it. */
onSetSeriesPlayed: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
@@ -212,6 +255,15 @@ internal fun SeriesDetailContent(
}
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
// Two sources, and both are needed. The series' own flag is what Emby says and what the
// launcher moves optimistically when the button is pressed, so it answers before the
// episodes have landed and it is what a rollback speaks through. The episode list is the
// page's own evidence, and it is the half that can be right when the flag is not — a
// show whose last episode was watched elsewhere arrives here with the tick already
// earned. Neither alone: a list holding episodes Emby will not mark (an unaired one it
// still lists) would never reach zero, and an empty list would claim every show watched.
val seriesWatched =
item.userData?.played == true || (episodes?.isNotEmpty() == true && remaining == 0)
// Recalculated from the episode list itself, so finishing an episode, marking one
// watched and a newly imported episode all move it with no cache to invalidate. The
// day is read once per composition of this page rather than on a timer: an estimate
@@ -396,6 +448,30 @@ internal fun SeriesDetailContent(
trailer?.let {
add(DetailHeroAction(MembyIcon.Movie.mark, "Play trailer", onClick = { onPlayTrailer(item) }))
}
add(
DetailHeroAction(
// The same tick the movie page uses, in the same place, because it is
// the same statement: a show marked watched is one the launcher stops
// asking about. Emby carries it down to every season and episode, and a
// newly imported episode brings the show back on its own.
icon = MembyIcon.CheckAll.mark,
description = if (seriesWatched) {
"Mark this show unwatched"
} else {
"Mark this show watched"
},
active = seriesWatched,
onClick = {
val desired = !seriesWatched
onSetSeriesPlayed(desired)
confirmation = if (desired) {
"Marked as watched"
} else {
"Marked as unwatched"
}
},
),
)
},
) { visibleTab ->
when (visibleTab) {
@@ -776,3 +852,17 @@ internal fun EpisodeCard(
Spacer(Modifier.width(12.dp))
}
}
/**
* One episode as it reads after the show it belongs to was marked watched, or unwatched.
*
* The playhead goes with the flag rather than being left where it was: Emby resets a
* finished title's position, and a card still carrying one would draw a progress bar under
* an episode that has just been declared finished.
*/
private fun BaseItem.withPlayed(played: Boolean): BaseItem = copy(
userData = (userData ?: UserItemData()).copy(
played = played,
playbackPositionTicks = if (played) 0L else userData?.playbackPositionTicks ?: 0L,
),
)
@@ -2,13 +2,11 @@
package com.ponzischeme89.memby.ui.genre
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
@@ -54,7 +52,6 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
@@ -69,7 +66,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
@@ -87,111 +83,6 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
fun GenreDiscoveryStrip(
itemType: String,
navigationFocusRequester: FocusRequester,
entryFocusRequester: FocusRequester,
onFocused: () -> Unit,
onOpenCategory: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val categories = remember(itemType) { genreCategoryTabs(itemType) }
Column(modifier.padding(vertical = 4.dp)) {
Text(
"Browse genres",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 36.dp, vertical = 6.dp),
)
LazyRow(
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
GenreDiscoveryCard(
category = category,
onFocused = onFocused,
onClick = { onOpenCategory(category.id) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(entryFocusRequester) else Modifier)
.focusProperties { if (index == 0) left = navigationFocusRequester },
)
}
}
}
}
@Composable
private fun GenreDiscoveryCard(
category: GenreCategory,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val visual = remember(category.icon) { genreVisual(category.icon) }
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Browse ${category.label}",
modifier = modifier.width(142.dp),
) { focused ->
Column {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.background(visual.colour, RoundedCornerShape(MembyCardCorner))
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
RoundedCornerShape(MembyCardCorner),
),
contentAlignment = Alignment.Center,
) {
Icon(
visual.icon,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(48.dp),
)
}
Spacer(Modifier.height(8.dp))
Text(
category.label,
color = if (focused) Color.White else MembyMutedText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
private data class GenreVisual(val icon: ImageVector, val colour: Color)
private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
GenreCategoryIcon.ALL -> GenreVisual(MembyIcon.Category.mark, Color(0xFF4F46A5))
GenreCategoryIcon.ACTION -> GenreVisual(MembyIcon.Bolt.mark, Color(0xFFB45309))
GenreCategoryIcon.COMEDY -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF15803D))
GenreCategoryIcon.CRIME -> GenreVisual(MembyIcon.Gavel.mark, Color(0xFF475569))
GenreCategoryIcon.DRAMA -> GenreVisual(MembyIcon.Drama.mark, Color(0xFF7E22CE))
GenreCategoryIcon.HORROR -> GenreVisual(MembyIcon.Fire.mark, Color(0xFF991B1B))
GenreCategoryIcon.MYSTERY -> GenreVisual(MembyIcon.Help.mark, Color(0xFF4338CA))
GenreCategoryIcon.SCI_FI -> GenreVisual(MembyIcon.Sparkle.mark, Color(0xFF0369A1))
GenreCategoryIcon.THRILLER -> GenreVisual(MembyIcon.HideWatched.mark, Color(0xFF0F766E))
GenreCategoryIcon.WAR -> GenreVisual(MembyIcon.Trophy.mark, Color(0xFF57534E))
GenreCategoryIcon.FAMILY -> GenreVisual(MembyIcon.Happy.mark, Color(0xFFDB2777))
GenreCategoryIcon.DOCUMENTARY -> GenreVisual(MembyIcon.VideoLibrary.mark, Color(0xFF0E7490))
GenreCategoryIcon.ROMANCE -> GenreVisual(MembyIcon.Favourite.mark, Color(0xFFBE185D))
GenreCategoryIcon.WESTERN -> GenreVisual(MembyIcon.Landscape.mark, Color(0xFF92400E))
GenreCategoryIcon.MUSIC -> GenreVisual(MembyIcon.Music.mark, Color(0xFF6D28D9))
GenreCategoryIcon.SPORT -> GenreVisual(MembyIcon.Football.mark, Color(0xFF047857))
GenreCategoryIcon.REALITY -> GenreVisual(MembyIcon.LiveTv.mark, Color(0xFFC2410C))
}
/**
* How long a genre may sit under focus before it is asked for.
*
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -70,6 +71,15 @@ internal fun RequestCard(
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
/**
* Whole percent of the download, or zero when nothing could measure one.
*
* Zero means "no figure" rather than "no bytes yet", which is how the wire means it
* so a download nothing has measured draws the plain chip and no bar rather than an
* empty bar claiming it has not started. Search candidates have no such figure and
* never pass one.
*/
progress: Int = 0,
/** Drawn in place of the trailing affordance while a request is in flight. */
busy: Boolean = false,
/** The trailing affordance, when pressing the card would do something. */
@@ -79,7 +89,7 @@ internal fun RequestCard(
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = listOf(title, statusLabel, detail)
contentDescription = listOf(title, requestStatusChipLabel(status, statusLabel, progress), detail)
.filter(String::isNotBlank)
.joinToString(", "),
modifier = modifier.fillMaxWidth(),
@@ -137,7 +147,7 @@ internal fun RequestCard(
if (status != RequestStatus.REQUESTABLE) {
RequestStatusBadge(
status = status,
label = statusLabel,
label = requestStatusChipLabel(status, statusLabel, progress),
modifier = Modifier.align(Alignment.TopStart).padding(9.dp),
)
}
@@ -172,6 +182,17 @@ internal fun RequestCard(
overflow = TextOverflow.Ellipsis,
)
}
// The bar sits above the sentence rather than under it, so the quiet line
// stays last on every card whether or not there is a bar — a card with no
// measurable download and one with a finished one must read as the same
// shape. requestProgressFraction is what decides there is one at all: only
// moving bytes have a denominator, and a bar standing somewhere arbitrary
// under a card that is merely being searched for would be the single most
// misleading thing on the page.
requestProgressFraction(status, progress)?.let { fraction ->
Spacer(Modifier.height(7.dp))
RequestProgressBar(fraction = fraction, colour = requestToneColour(status))
}
Spacer(Modifier.height(4.dp))
Text(
detail,
@@ -198,6 +219,42 @@ internal val RequestActionRequest = RequestCardAction(MembyIcon.Add, "Request")
internal val RequestActionPlay = RequestCardAction(MembyIcon.Play, "Watch")
internal val RequestActionRemove = RequestCardAction(MembyIcon.CheckCircle, "Remove")
/**
* How far along a download is.
*
* Deliberately not animated. The figure only ever moves when a poll answers, which is once
* every several seconds, and an `animateFloatAsState` read in a composable body would
* recompose this card sixty times a second for it the rule every animation in this app is
* held to. A bar that steps is also the more honest picture of a number that steps.
*
* The fill takes the state's own tone, so the bar and the chip above it are the same colour
* and neither has to be read against the other.
*/
@Composable
private fun RequestProgressBar(fraction: Float, colour: Color) {
Box(
Modifier
// Capped rather than filling the column, which on a card this wide drew a rule
// from the title to the trailing button and read as a separator. A gauge has to
// be short enough to be seen as one object: at this width the filled part is
// still judged against its own track from across a room.
// widthIn *before* fillMaxWidth: the outer modifier is what constrains the
// inner, so filling first hands the cap an exact width it can no longer narrow.
.widthIn(max = 300.dp)
.fillMaxWidth()
.height(5.dp)
.clip(RoundedCornerShape(3.dp))
.background(Color.White.copy(alpha = 0.14f)),
) {
Box(
Modifier
.fillMaxWidth(fraction.coerceIn(0f, 1f))
.fillMaxHeight()
.background(colour),
)
}
}
@Composable
private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) {
Column(
@@ -12,6 +12,21 @@ package com.ponzischeme89.memby.ui.requests
*/
object RequestStatus {
const val AVAILABLE = "available"
/**
* The four states a download client can answer for, which between them cover the span
* that used to be [PROCESSING] alone.
*
* They arrive only on a build that declares `request_progress_v1`; the gateway narrows
* all four back to [PROCESSING] for anything older, so this vocabulary is additive
* rather than a change of meaning on the wire.
*/
const val SEARCHING = "searching"
const val FOUND = "found"
const val DOWNLOADING = "downloading"
const val FAILED = "failed"
/** Now the last step only: the bytes have landed and are being filed away. */
const val PROCESSING = "processing"
const val PENDING = "pending"
const val REQUESTED = "requested"
@@ -30,9 +45,21 @@ enum class RequestTone { POSITIVE, ACTIVE, WAITING, NEUTRAL }
fun requestStatusTone(status: String): RequestTone = when (status) {
RequestStatus.AVAILABLE -> RequestTone.POSITIVE
RequestStatus.PROCESSING -> RequestTone.ACTIVE
RequestStatus.PENDING -> RequestTone.WAITING
RequestStatus.REQUESTED -> RequestTone.ACTIVE
// Everything Memby is actively doing shares one colour, so a card moving from searching
// to downloading to processing does not change colour three times on its way to green.
// The chip's *wording* is what tracks the progress; the colour tracks whether anything
// is happening at all.
RequestStatus.SEARCHING,
RequestStatus.FOUND,
RequestStatus.DOWNLOADING,
RequestStatus.PROCESSING,
RequestStatus.REQUESTED,
-> RequestTone.ACTIVE
// Amber rather than red, and deliberately the same amber as PENDING: both mean "waiting
// on something nobody in this house controls". A failed download is not a dead end —
// the *arr goes back to looking — and a red chip would tell a viewer to do something
// about it when there is nothing for them to do.
RequestStatus.PENDING, RequestStatus.FAILED -> RequestTone.WAITING
// Unavailable and anything this build has never heard of share the quiet treatment.
// A state with no meaning here must not borrow a colour that claims one.
else -> RequestTone.NEUTRAL
@@ -47,15 +74,51 @@ fun requestStatusLabel(status: String, serverLabel: String): String {
val supplied = serverLabel.trim()
if (supplied.isNotEmpty()) return supplied
return when (status) {
RequestStatus.AVAILABLE -> "Available"
RequestStatus.AVAILABLE -> "Ready to watch"
RequestStatus.SEARCHING -> "Searching"
RequestStatus.FOUND -> "Found"
RequestStatus.DOWNLOADING -> "Downloading"
RequestStatus.PROCESSING -> "Processing"
RequestStatus.PENDING -> "Pending"
RequestStatus.FAILED -> "Unable to download"
RequestStatus.UNAVAILABLE -> "Unavailable"
RequestStatus.REQUESTABLE -> "Request"
else -> "Requested"
}
}
/**
* The badge's wording with the percentage folded in: `Downloading · 43%`.
*
* The number is composed here rather than sent as part of the label, because the two change
* at completely different rates the wording is fixed for the length of a download and the
* figure moves every poll. A server sentence carrying the percentage would be a sentence
* that is stale the moment it is drawn, and it would mean the gateway could not cache a
* response for even a few seconds without lying about a number.
*
* Zero is treated as no figure, matching the wire: the gateway omits `progress` whenever
* nothing could say, so a download that has genuinely not moved a byte and one nothing has
* measured both read as plain "Downloading". Neither is a number worth printing.
*/
fun requestStatusChipLabel(status: String, serverLabel: String, progress: Int): String {
val label = requestStatusLabel(status, serverLabel)
if (status != RequestStatus.DOWNLOADING || progress !in 1..100) return label
return "$label · $progress%"
}
/**
* How far along to draw the bar, or null when there is no bar to draw.
*
* A bar is only ever drawn against moving bytes. Every other state has no measurable
* fraction a search has no denominator, an import is over in seconds and a progress bar
* standing at some arbitrary place under a card that is being searched for would be the
* single most misleading thing on the page.
*/
fun requestProgressFraction(status: String, progress: Int): Float? {
if (status != RequestStatus.DOWNLOADING || progress !in 1..100) return null
return progress / 100f
}
/**
* Whether pressing a search result would do anything.
*
@@ -148,11 +211,46 @@ enum class RequestGroup(val heading: String) {
fun requestGroupFor(status: String): RequestGroup = when (status) {
RequestStatus.AVAILABLE -> RequestGroup.READY
RequestStatus.PROCESSING, RequestStatus.PENDING, RequestStatus.REQUESTED ->
RequestGroup.IN_PROGRESS
// Failed sits under "On the way" with the rest, which is not a euphemism: the *arr goes
// straight back to looking for another release, so a viewer whose download failed is in
// exactly the position of one whose search has not turned anything up yet. Filing it
// under "Nothing happening" would say the opposite of what is true.
RequestStatus.SEARCHING,
RequestStatus.FOUND,
RequestStatus.DOWNLOADING,
RequestStatus.PROCESSING,
RequestStatus.PENDING,
RequestStatus.REQUESTED,
RequestStatus.FAILED,
-> RequestGroup.IN_PROGRESS
else -> RequestGroup.CLOSED
}
/**
* Whether the page has any reason to keep asking.
*
* The requests page is the one screen in the app whose content changes while somebody looks
* at it without them touching anything that is the whole point of showing a percentage
* so it polls. But it polls only while something is moving: a page of titles that all
* arrived last week, or that are all waiting on a release date months out, has nothing to
* refresh and must not spend a request every few seconds saying so on every television in
* the house.
*
* [RequestStatus.PENDING] is deliberately excluded. A film waiting on its digital release is
* not going to change in the next thirty seconds, and the ordinary refresh when somebody
* comes back to the page is soon enough.
*/
fun requestsWorthPolling(statuses: List<String>): Boolean = statuses.any {
it == RequestStatus.SEARCHING ||
it == RequestStatus.FOUND ||
it == RequestStatus.DOWNLOADING ||
it == RequestStatus.PROCESSING ||
it == RequestStatus.FAILED ||
// A request the gateway could not establish a state for is one worth asking about
// again: it usually means an *arr was restarting, which is a condition that clears.
it == RequestStatus.REQUESTED
}
/**
* The page's summary line. It counts what arrived rather than the total, because that is the
* number somebody came to the page for; the total is already visible as the length of the
@@ -419,6 +419,7 @@ private fun MyRequestsPane(
},
status = request.status,
statusLabel = requestStatusLabel(request.status, request.statusLabel),
progress = request.progress,
mediaType = request.mediaType,
artworkUrl = posterUrlFor(request.posterUrl),
// A request that has arrived opens the thing it became; anything else
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -83,6 +84,16 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
private var refreshJob: Job? = null
private var searchJob: Job? = null
/**
* How many of the viewer's own presses are still in flight.
*
* A poll is a second writer of the same list, and it holds the *server's* answer so
* one landing between an optimistic removal and the call that performs it would put the
* row back under the thumb that had just dismissed it. Counting rather than flagging,
* because a viewer can walk down the pane pressing several before the first answers.
*/
private var pendingMutations = 0
init {
refresh()
}
@@ -207,6 +218,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
if (key in _state.value.submitting) return
if (!requestCandidateActionable(candidate.status)) return
_state.update { it.copy(submitting = it.submitting + key, notice = null) }
pendingMutations++
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
.onSuccess { title ->
@@ -240,6 +252,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
)
}
}
pendingMutations--
}
}
@@ -261,6 +274,7 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
notice = null,
)
}
pendingMutations++
viewModelScope.launch {
runCatching { repository.removeMediaRequest(request.mediaType, request.foreignId) }
.onFailure { error ->
@@ -276,9 +290,62 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
)
}
}
pendingMutations--
}
}
/**
* Keeps the list current while somebody is looking at it.
*
* This is the one page in the app whose content changes while it is on screen without
* anybody touching anything a percentage that never moves is not a percentage so it
* asks again on a timer. Three things bound what that costs, and all three matter:
*
* - **It is the caller's coroutine, not [viewModelScope].** The view model is keyed on
* the profile and outlives the page, so a loop of its own would go on polling with the
* page closed and the launcher in front of somebody. The host runs this inside
* `repeatOnLifecycle(STARTED)`, which is also what stops a backgrounded television
* asking every fifteen seconds at a set nobody is watching the status loop's stance.
* - **It asks only while something is moving** ([requestsWorthPolling]). A page of
* titles that all arrived last week has nothing to refresh, and must not spend a
* request saying so on every television in the house.
* - **It asks only over the pane it would change.** With the search half open the list
* is not on screen, and coming back to it refreshes anyway ([selectTab]).
*/
suspend fun pollWhileVisible() {
while (true) {
delay(POLL_INTERVAL_MS)
val current = _state.value
if (current.tab != RequestsTab.MINE) continue
if (pendingMutations > 0) continue
if (!requestsWorthPolling(current.requests.map(GatewayMediaRequestItem::status))) continue
refreshQuietly()
}
}
/**
* A refresh nothing on screen reacts to except the cards themselves.
*
* It deliberately does not touch `loadingRequests` or `requestsError`: the summary line
* stands down while that flag is up, so a poll would blink it away every fifteen
* seconds, and a single failed poll must not replace a list somebody is reading with an
* apology. A poll that fails is simply a poll that changed nothing the next one is
* fifteen seconds away, and the page's own Try again is still there for a list that
* never loaded in the first place.
*/
private suspend fun refreshQuietly() {
runCatching { repository.getMyRequests() }
.onSuccess { list ->
// The viewer may have pressed something while the answer was in flight, and
// their press is the more recent truth.
if (pendingMutations > 0) return@onSuccess
_state.update { it.copy(requests = list.requests, allowed = list.allowed) }
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
}
}
fun dismissNotice() = _state.update { it.copy(notice = null) }
companion object {
@@ -292,6 +359,17 @@ class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
* genuinely be looking for.
*/
const val MIN_QUERY_LENGTH = 2
/**
* Fifteen seconds, which is the gateway's own `requestQueueTTL`.
*
* The two are the same figure on purpose: the download queues behind this answer are
* cached for that window precisely so a house full of televisions polling costs one
* upstream read, and asking any faster would spend a round trip to be handed the
* same cached answer back. Slower and the number on the card would visibly lag the
* one the gateway already holds.
*/
const val POLL_INTERVAL_MS = 15_000L
}
}
@@ -100,6 +100,33 @@ class RadarrMovieDetailScreenshotTest {
}
}
/**
* The worst case the gateway can produce: a title that wraps onto two lines, an
* original title under it, four facts, scores, three release dates, a long description
* and a trailer to offer. The claim being checked is that the buttons are still whole
* and still on the page the supporting material is what gives way, not the one thing
* anybody came here to press.
*/
@Test
fun `a film carrying everything at once`() {
capture("radarr-crowded") {
RadarrMovieDetailContent(
card = card,
detail = full.copy(
title = "The Quiet Coast and the Long Way Back to Whangaparāoa",
originalTitle = "Te Takutai Marino",
overview = "A harbour town in winter, and the constable who has stopped " +
"pretending the tide brings anything back. Adapted from the novel, " +
"and filmed over two winters on the coast it is named for, with a " +
"cast drawn almost entirely from the towns along it. The first of " +
"three the studio has announced.",
),
onPlayTrailer = {},
onClose = {},
)
}
}
/**
* The opening frame, before the request answers. The page is drawn from the card that
* was pressed, so what matters is that it is already recognisably this film rather than
@@ -46,6 +46,26 @@ class RequestsScreenshotTest {
capture("requests-mine", RequestsUiState(requests = sampleRequests, loadingRequests = false))
}
/**
* The four states a download client can answer for, which is the whole reason the page
* polls.
*
* It is its own capture rather than four more cards on the ordinary one because the
* thing worth looking at here is a comparison a unit test cannot make: the bar under
* "Downloading - 43%" against the identical card whose client would not say how far
* along it is, which must draw no bar at all rather than an empty one. A card whose
* figure is missing and a card that has genuinely not moved a byte are the same picture
* on purpose neither is a number worth printing and the sentence underneath is what
* carries the difference.
*/
@Test
fun `a download in progress`() {
capture(
"requests-mine-downloading",
RequestsUiState(requests = downloadingRequests, loadingRequests = false),
)
}
/** Nothing asked for yet. The empty state has a control, so focus has somewhere to go. */
@Test
fun `nothing requested yet`() {
@@ -237,6 +257,37 @@ class RequestsScreenshotTest {
}
}
/**
* One card per download state, in the order a request travels through them.
*
* The wording is the gateway's own, copied from `requestStatusDetail`, so the capture cannot
* quietly disagree with what a television is actually sent.
*/
private val downloadingRequests = listOf(
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
requestedAt = "2026-08-19T09:00:00Z", status = RequestStatus.SEARCHING,
statusLabel = "Searching", statusDetail = "We haven't found a suitable release yet",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 121361, title = "Silo", year = 2023,
requestedAt = "2026-08-19T08:40:00Z", status = RequestStatus.DOWNLOADING,
statusLabel = "Downloading", statusDetail = "Estimated ready in ~14 minutes",
progress = 43, estimatedReadySeconds = 840,
),
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 533535, title = "The Thursday Murder Club", year = 2026,
requestedAt = "2026-08-19T08:20:00Z", status = RequestStatus.DOWNLOADING,
statusLabel = "Downloading", statusDetail = "We'll let you know when it's ready",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 94997, title = "Slow Horses", year = 2022,
requestedAt = "2026-08-19T07:55:00Z", status = RequestStatus.FAILED,
statusLabel = "Unable to download",
statusDetail = "Memby will keep looking for another release",
),
)
private val sampleRequests = listOf(
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,