0.2.78
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user