0.2.58 - Requests module
This commit is contained in:
@@ -42,7 +42,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.57"
|
||||
val defaultVersionName = "0.2.58"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -220,6 +220,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
private val snapshot: Settings
|
||||
get() = settings.current ?: observedSettings
|
||||
|
||||
/**
|
||||
* Whether the signed-in viewer may ask the household for titles, as last reported by
|
||||
* [MaintenanceMonitor]'s status poll.
|
||||
*
|
||||
* Held here rather than read from the monitor so this class keeps talking only to Emby
|
||||
* and its own settings — the same reason [observedSettings] is a volatile snapshot
|
||||
* rather than a suspending read. It is **false until a poll says otherwise**, which is
|
||||
* what makes the direct path, a signed-out set and an older gateway all refuse by
|
||||
* default.
|
||||
*/
|
||||
@Volatile
|
||||
var requestsPermitted: Boolean = false
|
||||
internal set
|
||||
|
||||
init {
|
||||
scope.launch { settings.settingsFlow.collect { observedSettings = it } }
|
||||
}
|
||||
@@ -863,8 +877,17 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Titles the household could be asked for, matching [term].
|
||||
*
|
||||
* A viewer without permission gets an empty list rather than an exception. This is still
|
||||
* enforcement — nothing is fetched and nothing is shown — but silence is the right shape
|
||||
* here, because this decorates the ordinary Search tab: throwing would put "media
|
||||
* requests are not enabled for this user" under the results of everybody who simply
|
||||
* searched for something the library does not have.
|
||||
*/
|
||||
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
if (!ServerConfig.isGateway || !requestsPermitted) return emptyList()
|
||||
return requireGateway().requestLookup(term.trim()).candidates
|
||||
}
|
||||
|
||||
@@ -872,6 +895,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
|
||||
): String {
|
||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||
requireRequestPermission()
|
||||
return requireGateway().requestMedia(
|
||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||
mediaType = candidate.mediaType,
|
||||
@@ -881,6 +905,41 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
).title
|
||||
}
|
||||
|
||||
/** This viewer's own asks, newest first, with the state each has reached. */
|
||||
suspend fun getMyRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList {
|
||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||
requireRequestPermission()
|
||||
return requireGateway().myRequests()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listing a title among this viewer's requests. It deliberately does not cancel
|
||||
* anything downstream — the household may be part-way through fetching it, and somebody
|
||||
* else may have asked for the same thing.
|
||||
*/
|
||||
suspend fun removeMediaRequest(mediaType: String, foreignId: Int) {
|
||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||
requireRequestPermission()
|
||||
requireGateway().deleteRequest(mediaType, foreignId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The data layer's own permission check, deliberately *not* only the hidden menu entry.
|
||||
*
|
||||
* Hiding the switcher item is a courtesy — it keeps a viewer from opening a page that can
|
||||
* only apologise. It is not enforcement, because the flag it reads is a poll result: a
|
||||
* television holds a stale `true` for up to ten seconds after an operator withdraws
|
||||
* access, and every screen that can reach these methods is composed from state that
|
||||
* outlives a single poll. So every request path asserts here as well.
|
||||
*
|
||||
* The gateway remains the authority — it refuses with 403 regardless of what this thinks
|
||||
* — and this check is what turns that refusal into something the app never has to make,
|
||||
* rather than a round trip that spends a viewer's time to be told no.
|
||||
*/
|
||||
private fun requireRequestPermission() {
|
||||
check(requestsPermitted) { "Media requests are not enabled for this user" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommendation rows on their own, forcing the gateway to build them synchronously
|
||||
* if its cache is cold. [getHome] already carries them once warm, so this is only
|
||||
|
||||
@@ -93,6 +93,7 @@ class MaintenanceMonitor(
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||
private val _requestsAllowed = MutableStateFlow(false)
|
||||
private val _gatewayVersion = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
@@ -131,9 +132,34 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
val tvCalendarEnabled: StateFlow<Boolean> = _tvCalendarEnabled.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether this viewer may ask the household for titles, as of the last poll.
|
||||
*
|
||||
* This is what keeps the Requests entry out of the switcher for everybody else, and it
|
||||
* rides this poll rather than the sign-in because an operator granting somebody access
|
||||
* must reach a television that is already switched on — the seasonal-theme precedent.
|
||||
*
|
||||
* It is **false whenever the server has not said otherwise**: signed out, mid-outage, on
|
||||
* a gateway that predates the field, or on the direct path where there is nobody to ask.
|
||||
* A menu entry conjured by a missing field would lead to a page every handler refuses.
|
||||
* Hiding it is a courtesy, never the enforcement — see [EmbyRepository.getMyRequests].
|
||||
*/
|
||||
val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow()
|
||||
|
||||
/** Build reported by the connected gateway, for Settings → About. */
|
||||
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
|
||||
|
||||
/**
|
||||
* The one writer for request permission, so the flow the switcher reads and the flag the
|
||||
* repository enforces on can never disagree. Two fields set separately is exactly how a
|
||||
* hidden menu item ends up in front of a viewer whose data layer will refuse them, or
|
||||
* the reverse.
|
||||
*/
|
||||
private fun setRequestsAllowed(allowed: Boolean) {
|
||||
_requestsAllowed.value = allowed
|
||||
repository.requestsPermitted = allowed
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerts this television has already shown.
|
||||
*
|
||||
@@ -205,6 +231,7 @@ class MaintenanceMonitor(
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
setRequestsAllowed(false)
|
||||
_gatewayVersion.value = ""
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
@@ -238,6 +265,7 @@ class MaintenanceMonitor(
|
||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
|
||||
setRequestsAllowed(status.requests.allowed)
|
||||
_gatewayVersion.value = status.gatewayVersion
|
||||
// Emby's state is reported even during maintenance: an
|
||||
// operator taking Memby down while Emby is also unreachable
|
||||
@@ -269,6 +297,7 @@ class MaintenanceMonitor(
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
setRequestsAllowed(false)
|
||||
_gatewayVersion.value = ""
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
|
||||
@@ -192,8 +192,20 @@ data class GatewayServiceStatus(
|
||||
* with, which is the palette everything looked like before themes existed.
|
||||
*/
|
||||
val theme: GatewayThemeStatus = GatewayThemeStatus(),
|
||||
/**
|
||||
* Whether this viewer may ask the household for titles.
|
||||
*
|
||||
* Per person rather than per household, which is why it is not in [features] beside the
|
||||
* rest: the allowlist is the operator's decision about one account. It defaults to
|
||||
* **not allowed**, so a gateway that predates the field leaves the Requests entry off
|
||||
* the switcher rather than offering a menu item its own handlers would refuse.
|
||||
*/
|
||||
val requests: GatewayRequestAccess = GatewayRequestAccess(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestAccess(val allowed: Boolean = false)
|
||||
|
||||
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
|
||||
@Serializable
|
||||
data class GatewayThemeStatus(
|
||||
@@ -479,6 +491,45 @@ data class GatewayRequestCandidate(
|
||||
val posterUrl: String = "",
|
||||
val alreadyAdded: Boolean = false,
|
||||
val inLibrary: Boolean = false,
|
||||
/**
|
||||
* What pressing this card would mean, decided by the gateway rather than worked out here
|
||||
* from the booleans above — so the search pane and the requests page can never disagree
|
||||
* about the same title, and a state added to the server reaches this build unchanged.
|
||||
* Empty from a gateway that predates it, which [requestCandidateStatus] reads as the
|
||||
* old behaviour.
|
||||
*/
|
||||
val status: String = "",
|
||||
val statusLabel: String = "",
|
||||
/** Whether *this viewer* asked for it, which [alreadyAdded] cannot distinguish. */
|
||||
val mine: Boolean = false,
|
||||
val released: Boolean = false,
|
||||
)
|
||||
|
||||
/** One card on the viewer's own requests page. */
|
||||
@Serializable
|
||||
data class GatewayMediaRequestItem(
|
||||
val mediaType: String = "",
|
||||
val foreignId: Int = 0,
|
||||
val title: String = "",
|
||||
val year: Int = 0,
|
||||
val overview: String = "",
|
||||
val posterUrl: String = "",
|
||||
val requestedAt: String = "",
|
||||
val status: String = "",
|
||||
val statusLabel: String = "",
|
||||
val statusDetail: String = "",
|
||||
/** Set once Emby has imported it, so an arrived request can open its own detail page. */
|
||||
val embyItemId: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayMediaRequestList(
|
||||
val requests: List<GatewayMediaRequestItem> = emptyList(),
|
||||
/**
|
||||
* Rides the response so a television whose permission was withdrawn while the page was
|
||||
* open is told, rather than reading an empty list as "you have asked for nothing".
|
||||
*/
|
||||
val allowed: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -115,6 +115,15 @@ interface GatewayApi {
|
||||
@POST("v1/requests")
|
||||
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
||||
|
||||
@GET("v1/requests")
|
||||
suspend fun myRequests(): com.ponzischeme89.memby.data.model.GatewayMediaRequestList
|
||||
|
||||
@retrofit2.http.DELETE("v1/requests/{mediaType}/{foreignId}")
|
||||
suspend fun deleteRequest(
|
||||
@Path("mediaType") mediaType: String,
|
||||
@Path("foreignId") foreignId: Int,
|
||||
)
|
||||
|
||||
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||
@GET("v1/recommendations")
|
||||
suspend fun recommendations(): GatewayRows
|
||||
|
||||
@@ -98,6 +98,7 @@ import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.LiveTv
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.PlaylistRemove
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
@@ -432,10 +433,22 @@ fun UserSwitcherOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
alertCount: Int = 0,
|
||||
onOpenAlerts: () -> Unit = {},
|
||||
/**
|
||||
* Whether this viewer may ask the household for titles. False hides the entry entirely
|
||||
* rather than dimming it: an operator's allowlist is not something a viewer can act on,
|
||||
* so a greyed row would only ever raise a question nothing on this television can
|
||||
* answer. The data layer refuses regardless — see [EmbyRepository.requestsPermitted].
|
||||
*/
|
||||
showRequests: Boolean = false,
|
||||
onOpenRequests: () -> Unit = {},
|
||||
) {
|
||||
val profileIds = profiles.map(EmbyProfile::id)
|
||||
val focusRequesters = remember(profileIds) {
|
||||
List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
|
||||
val actionCount = userSwitcherActionCount(showRequests)
|
||||
// Re-keyed on the action count as well as the profiles: a permission arriving on a poll
|
||||
// while this menu is open changes how many rows there are, and a requester list of the
|
||||
// old length would leave the new row unfocusable.
|
||||
val focusRequesters = remember(profileIds, actionCount) {
|
||||
List(profiles.size + actionCount) { FocusRequester() }
|
||||
}
|
||||
val profileListState = remember(profileIds) { LazyListState() }
|
||||
val focusScope = rememberCoroutineScope()
|
||||
@@ -483,7 +496,7 @@ fun UserSwitcherOverlay(
|
||||
currentIndex = focusedIndex,
|
||||
profileCount = profiles.size,
|
||||
direction = direction,
|
||||
actionCount = UserSwitcherActionCount,
|
||||
actionCount = actionCount,
|
||||
)
|
||||
focusedIndex = next
|
||||
profileFocusJob?.cancel()
|
||||
@@ -567,13 +580,28 @@ fun UserSwitcherOverlay(
|
||||
},
|
||||
onClick = onOpenAlerts,
|
||||
)
|
||||
// Requests sits between the two because it is news-shaped like Notifications
|
||||
// rather than administrative like Manage users, which stays last.
|
||||
if (showRequests) {
|
||||
UserSwitcherAction(
|
||||
label = "Requests",
|
||||
icon = Icons.Default.PlaylistAdd,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[profiles.size + 1])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = profiles.size + 1
|
||||
},
|
||||
onClick = onOpenRequests,
|
||||
)
|
||||
}
|
||||
val manageIndex = profiles.size + actionCount - 1
|
||||
UserSwitcherAction(
|
||||
label = "Manage users",
|
||||
icon = Icons.Default.Settings,
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequesters[profiles.size + 1])
|
||||
.focusRequester(focusRequesters[manageIndex])
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) focusedIndex = profiles.size + 1
|
||||
if (it.isFocused) focusedIndex = manageIndex
|
||||
},
|
||||
onClick = onManageProfiles,
|
||||
)
|
||||
@@ -581,8 +609,14 @@ fun UserSwitcherOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
/** Notifications, then Manage users. See [userSwitcherNextIndex]. */
|
||||
private const val UserSwitcherActionCount = 2
|
||||
/**
|
||||
* Notifications, then Requests when this viewer may make them, then Manage users.
|
||||
*
|
||||
* Pure and derived in one place because three things read it — the requester list's length,
|
||||
* the D-pad's lower bound and Manage users' own index — and a count that disagreed with the
|
||||
* rows actually drawn is how the last item in a menu becomes unreachable.
|
||||
*/
|
||||
internal fun userSwitcherActionCount(showRequests: Boolean): Int = if (showRequests) 3 else 2
|
||||
|
||||
@Composable
|
||||
private fun UserSwitcherProfileItem(
|
||||
|
||||
@@ -458,6 +458,17 @@ private fun FeaturedMovieCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The same cue the minis and the poster shelves draw. Without it the one card
|
||||
// the launcher opens on was the only focusable artwork in the app that did not
|
||||
// say a press would play it. It sits in the artwork half, clear of the text
|
||||
// column, which stops at 58% of the width.
|
||||
if (focused) {
|
||||
MembyArtworkPlayCue(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 26.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,9 @@ import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||
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
|
||||
@@ -1925,6 +1928,9 @@ private fun HomeScreen(
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||
// Per viewer rather than per household, so it cannot ride the feature map: it decides
|
||||
// whether the Requests entry is in the switcher at all.
|
||||
val requestsAllowed by ServiceLocator.maintenance.requestsAllowed.collectAsStateWithLifecycle()
|
||||
// Only whether there is one, not its countdown — that is collected inside the banner
|
||||
// so a ticking second never reaches the launcher. This is read here purely to decide
|
||||
// which of the two top bars gets the strip.
|
||||
@@ -1975,6 +1981,7 @@ private fun HomeScreen(
|
||||
),
|
||||
) + notificationState.notifications
|
||||
var showNotifications by remember { mutableStateOf(false) }
|
||||
var showRequests by remember { mutableStateOf(false) }
|
||||
// Two different things: [launchingItem] is the gate that stops a second Play press
|
||||
// stacking a second player, and stays shut until one comes back. [resolvingItem] is
|
||||
// the loading screen, and belongs only to a launch that is waiting on the server.
|
||||
@@ -2018,6 +2025,12 @@ private fun HomeScreen(
|
||||
val heroRowEntryFocusRequester = remember { FocusRequester() }
|
||||
// Feature flags can remove the node that currently owns focus. Move the route first,
|
||||
// wait for its replacement to compose, then establish an explicit remote target.
|
||||
// A viewer standing on the Requests page when an operator withdraws access is moved
|
||||
// off it, the same rule the calendar destination follows: a page nothing behind it will
|
||||
// answer must not be somewhere a remote is left sitting.
|
||||
LaunchedEffect(requestsAllowed) {
|
||||
if (!requestsAllowed) showRequests = false
|
||||
}
|
||||
LaunchedEffect(tvCalendarEnabled) {
|
||||
if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
@@ -2116,6 +2129,7 @@ private fun HomeScreen(
|
||||
showProfiles = false
|
||||
userSwitcherVisible = false
|
||||
showNotifications = false
|
||||
showRequests = false
|
||||
detailsItem = null
|
||||
detailsAiringNotice = null
|
||||
quickMenuItem = null
|
||||
@@ -2330,6 +2344,7 @@ private fun HomeScreen(
|
||||
}
|
||||
val journeyScreen = when {
|
||||
showSettings -> "settings"
|
||||
showRequests -> "requests"
|
||||
showNotifications -> "notifications"
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
@@ -3131,6 +3146,16 @@ private fun HomeScreen(
|
||||
notificationsLoading = false
|
||||
}
|
||||
},
|
||||
showRequests = requestsAllowed,
|
||||
onOpenRequests = {
|
||||
homeViewModel.trackJourney(
|
||||
category = "requests", action = "open", screen = journeyScreen,
|
||||
feature = "requests", target = "requests",
|
||||
)
|
||||
userSwitcherVisible = false
|
||||
navigationExpanded = false
|
||||
showRequests = true
|
||||
},
|
||||
onDismiss = {
|
||||
userSwitcherVisible = false
|
||||
scope.launch {
|
||||
@@ -3394,6 +3419,54 @@ private fun HomeScreen(
|
||||
onClose = { closeMyShow(false) },
|
||||
)
|
||||
}
|
||||
if (showRequests) {
|
||||
// Reached from the user picker, so leaving it returns to the rail rather than to
|
||||
// whatever card held focus on the launcher behind it — the alerts page's rule.
|
||||
val closeRequests: () -> Unit = {
|
||||
showRequests = false
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { navigationFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
// Keyed on the profile like every other page here, so switching viewer cannot
|
||||
// leave one person's requests on screen under somebody else's name.
|
||||
val requestsViewModel: RequestsViewModel = viewModel(
|
||||
viewModelStoreOwner = profileViewModelOwner,
|
||||
factory = RequestsViewModelFactory(repo),
|
||||
)
|
||||
val requestsState by requestsViewModel.state.collectAsStateWithLifecycle()
|
||||
RequestsScreen(
|
||||
state = requestsState,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
onSelectTab = requestsViewModel::selectTab,
|
||||
onQueryChanged = requestsViewModel::onQueryChanged,
|
||||
onAppendToQuery = requestsViewModel::appendToQuery,
|
||||
onBackspace = requestsViewModel::backspace,
|
||||
onClearQuery = requestsViewModel::clearQuery,
|
||||
onRequest = requestsViewModel::request,
|
||||
onRemove = requestsViewModel::remove,
|
||||
onOpenItem = { itemId ->
|
||||
// A request that has arrived opens the thing it became. The stub is
|
||||
// filled in by focusItem the way a schedule card's is, so the page
|
||||
// appears at once instead of waiting on an item request.
|
||||
scope.launch {
|
||||
runCatching { repo.getItemDetails(itemId) }
|
||||
.onSuccess { item ->
|
||||
closeRequests()
|
||||
detailsAiringNotice = null
|
||||
detailsTrail = emptyList()
|
||||
detailsItem = item
|
||||
}
|
||||
}
|
||||
},
|
||||
onRetry = requestsViewModel::refresh,
|
||||
onExit = closeRequests,
|
||||
posterUrlFor = { it.takeIf(String::isNotBlank) },
|
||||
modifier = Modifier.zIndex(6f),
|
||||
)
|
||||
}
|
||||
if (showNotifications) {
|
||||
// Reached from the user picker, so leaving it goes back to the rail rather than
|
||||
// to whatever card happened to hold focus on the launcher behind it.
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
|
||||
/**
|
||||
* The card both panes are built from.
|
||||
*
|
||||
* Deliberately the TV calendar's programme card rather than a new shape: the same 118dp
|
||||
* row, the same 214dp artwork block bleeding into the panel under a horizontal gradient,
|
||||
* the same white 2dp focus border over a 1dp resting one, the same badge in the artwork's
|
||||
* top-left corner. A request and an episode airing on Thursday are the same kind of object —
|
||||
* something the household is going to have — so they read as one system.
|
||||
*
|
||||
* Everything is a parameter and nothing is fetched here, which is what lets
|
||||
* `RequestsScreenshotTest` render the real cards with no server, and what keeps the status
|
||||
* vocabulary in [RequestPresentation] rather than smeared through the layout.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
detail: String,
|
||||
status: String,
|
||||
statusLabel: String,
|
||||
mediaType: String,
|
||||
artworkUrl: String?,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** 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. */
|
||||
action: RequestCardAction? = null,
|
||||
) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = listOf(title, statusLabel, detail)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(", "),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) { focused ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(118.dp)
|
||||
.clip(shape)
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.72f))
|
||||
.border(
|
||||
if (focused) 2.dp else 1.dp,
|
||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||
shape,
|
||||
),
|
||||
) {
|
||||
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) {
|
||||
// The monogram sits behind the artwork rather than instead of it, so nothing
|
||||
// has to decide in advance whether a poster will arrive — the cast panel's
|
||||
// rule. Radarr and Sonarr posters are remote URLs and often slow.
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.linearGradient(
|
||||
listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
title.trim().firstOrNull()?.uppercase().orEmpty(),
|
||||
color = Color.White.copy(alpha = 0.16f),
|
||||
fontSize = 52.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
if (!artworkUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = artworkUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised),
|
||||
),
|
||||
),
|
||||
)
|
||||
// A requestable title wears no badge. The trailing affordance already says
|
||||
// "Request" in the accent, and a second grey chip saying the same word is
|
||||
// the one thing on the card that is not a *state* — badges here mean what
|
||||
// has become of something, and this one has had nothing become of it yet.
|
||||
if (status != RequestStatus.REQUESTABLE) {
|
||||
RequestStatusBadge(
|
||||
status = status,
|
||||
label = statusLabel,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(9.dp),
|
||||
)
|
||||
}
|
||||
MediaTypeGlyph(
|
||||
mediaType = mediaType,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(9.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
title,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Text(
|
||||
subtitle,
|
||||
color = Color.White.copy(alpha = 0.82f),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
detail,
|
||||
color = requestToneColour(status),
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (busy) {
|
||||
RequestTrailing(icon = Icons.Default.Schedule, label = "Asking", focused = focused)
|
||||
} else if (action != null) {
|
||||
RequestTrailing(icon = action.icon, label = action.label, focused = focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The trailing affordance on a card: what the centre button would do. */
|
||||
internal data class RequestCardAction(val icon: ImageVector, val label: String)
|
||||
|
||||
internal val RequestActionRequest = RequestCardAction(Icons.Default.Add, "Request")
|
||||
internal val RequestActionPlay = RequestCardAction(Icons.Default.PlayArrow, "Watch")
|
||||
internal val RequestActionRemove = RequestCardAction(Icons.Default.CheckCircle, "Remove")
|
||||
|
||||
@Composable
|
||||
private fun RequestTrailing(icon: ImageVector, label: String, focused: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxHeight().width(86.dp).padding(end = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(38.dp)
|
||||
.background(
|
||||
if (focused) Color.White else MembyControlSurfaceRaised,
|
||||
CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = if (focused) MembySurface else Color.White,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
label.uppercase(),
|
||||
color = if (focused) Color.White else MembyQuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state, worn in the artwork's corner where the calendar wears its finale badge.
|
||||
*
|
||||
* The tone is passed through [requestStatusTone] rather than derived from the label, so a
|
||||
* state this build has never seen gets the neutral plate and its server-sent wording instead
|
||||
* of borrowing a colour that would claim something.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
val (background, foreground) = when (requestStatusTone(status)) {
|
||||
// The same four colours the launcher's LifecycleBadge uses, so the words are read
|
||||
// the same way wherever they appear.
|
||||
RequestTone.POSITIVE -> MembyAccent to MembyAccentInk
|
||||
RequestTone.ACTIVE -> Color(0xFF5DA9FF) to MembySurface
|
||||
RequestTone.WAITING -> Color(0xFFFFB454) to MembySurface
|
||||
RequestTone.NEUTRAL -> MembyControlSurfaceRaised to MembyOnSurface
|
||||
}
|
||||
Text(
|
||||
text = label.uppercase(),
|
||||
color = foreground,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(background)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** The accent the detail line is drawn in, matching the badge's plate. */
|
||||
@Composable
|
||||
internal fun requestToneColour(status: String): Color = when (requestStatusTone(status)) {
|
||||
RequestTone.POSITIVE -> MembyAccent
|
||||
RequestTone.ACTIVE -> Color(0xFF5DA9FF)
|
||||
RequestTone.WAITING -> Color(0xFFFFB454)
|
||||
RequestTone.NEUTRAL -> MembyMutedText
|
||||
}
|
||||
|
||||
/**
|
||||
* Film or series, said with a glyph rather than a word.
|
||||
*
|
||||
* Both panes mix the two, and on a request page the distinction genuinely matters — a
|
||||
* series takes weeks to arrive and a film does not — but it is not worth a second text
|
||||
* badge competing with the state.
|
||||
*/
|
||||
@Composable
|
||||
private fun MediaTypeGlyph(mediaType: String, modifier: Modifier = Modifier) {
|
||||
val icon = if (mediaType == "series") Icons.Default.Tv else Icons.Default.Movie
|
||||
Box(
|
||||
modifier
|
||||
.size(24.dp)
|
||||
.background(MembySurface.copy(alpha = 0.72f), RoundedCornerShape(6.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = if (mediaType == "series") "Series" else "Film",
|
||||
tint = Color.White.copy(alpha = 0.82f),
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The card's shape with nothing in it, shown while the first list is loading.
|
||||
*
|
||||
* A skeleton rather than a spinner because the page has a strong, regular shape and the
|
||||
* honest thing to say while it loads is what is about to be there. It is not focusable:
|
||||
* a viewer must never land on a placeholder and press it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RequestCardSkeleton(modifier: Modifier = Modifier) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
Row(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(118.dp)
|
||||
.clip(shape)
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.34f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.05f), shape),
|
||||
) {
|
||||
Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface.copy(alpha = 0.55f)))
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxHeight().padding(horizontal = 14.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
SkeletonBar(widthFraction = 0.52f, height = 15.dp)
|
||||
Spacer(Modifier.height(9.dp))
|
||||
SkeletonBar(widthFraction = 0.32f, height = 11.dp)
|
||||
Spacer(Modifier.height(7.dp))
|
||||
SkeletonBar(widthFraction = 0.22f, height = 9.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkeletonBar(widthFraction: Float, height: androidx.compose.ui.unit.Dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(widthFraction)
|
||||
.height(height)
|
||||
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(4.dp)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
/**
|
||||
* How a request state is drawn, kept apart from the composables so it can be unit-tested
|
||||
* and so the two panes — a viewer's own requests and a search result — can never colour the
|
||||
* same word differently.
|
||||
*
|
||||
* The slugs are the gateway's ([RequestStatus]), and an unknown one is deliberately legal:
|
||||
* a state invented on the server must reach an older television as a neutral chip wearing
|
||||
* whatever label it was sent, not as a blank card or a crash. That is the whole reason the
|
||||
* server sends `statusLabel` beside `status`.
|
||||
*/
|
||||
object RequestStatus {
|
||||
const val AVAILABLE = "available"
|
||||
const val PROCESSING = "processing"
|
||||
const val PENDING = "pending"
|
||||
const val REQUESTED = "requested"
|
||||
const val UNAVAILABLE = "unavailable"
|
||||
const val REQUESTABLE = "requestable"
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour a state is drawn in.
|
||||
*
|
||||
* Four tones, matching the meanings the launcher's [LifecycleBadge] already establishes, so
|
||||
* a viewer reads them the same way across the app: green is done, blue is in hand, amber is
|
||||
* waiting on something nobody controls, grey is nothing to say.
|
||||
*/
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* The label to draw. The server's wording wins whenever it sent any, which is what lets a
|
||||
* new state read correctly on a television that predates it; the local table is only the
|
||||
* fallback for a gateway too old to send one.
|
||||
*/
|
||||
fun requestStatusLabel(status: String, serverLabel: String): String {
|
||||
val supplied = serverLabel.trim()
|
||||
if (supplied.isNotEmpty()) return supplied
|
||||
return when (status) {
|
||||
RequestStatus.AVAILABLE -> "Available"
|
||||
RequestStatus.PROCESSING -> "Processing"
|
||||
RequestStatus.PENDING -> "Pending"
|
||||
RequestStatus.UNAVAILABLE -> "Unavailable"
|
||||
RequestStatus.REQUESTABLE -> "Request"
|
||||
else -> "Requested"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether pressing a search result would do anything.
|
||||
*
|
||||
* Only a title nothing has and nobody has asked for is actionable. A card the viewer already
|
||||
* requested is deliberately *not* — pressing it again would be a second identical ask, and
|
||||
* the card already says so. This is the client half of a rule the gateway enforces anyway;
|
||||
* it exists so the button is never offered rather than offered and refused.
|
||||
*/
|
||||
fun requestCandidateActionable(status: String): Boolean = when (status) {
|
||||
RequestStatus.REQUESTABLE -> true
|
||||
// A gateway that predates the status field sends nothing. Falling back to actionable
|
||||
// preserves the behaviour those builds already had, where every candidate could be
|
||||
// pressed and the server decided.
|
||||
"" -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
/**
|
||||
* What a card is grouped under on the viewer's own page.
|
||||
*
|
||||
* Three bands rather than six, because the question somebody opens this page with is "has
|
||||
* anything I asked for arrived", and a page sorted into one heading per state answers it
|
||||
* with scrolling. Arrived first, in hand second, and everything with nothing left to happen
|
||||
* to it last.
|
||||
*/
|
||||
enum class RequestGroup(val heading: String) {
|
||||
READY("Ready to watch"),
|
||||
IN_PROGRESS("On the way"),
|
||||
CLOSED("Nothing happening"),
|
||||
}
|
||||
|
||||
fun requestGroupFor(status: String): RequestGroup = when (status) {
|
||||
RequestStatus.AVAILABLE -> RequestGroup.READY
|
||||
RequestStatus.PROCESSING, RequestStatus.PENDING, RequestStatus.REQUESTED ->
|
||||
RequestGroup.IN_PROGRESS
|
||||
else -> RequestGroup.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* list.
|
||||
*/
|
||||
fun requestSummaryLabel(total: Int, ready: Int): String = when {
|
||||
total == 0 -> "Nothing requested yet"
|
||||
ready == 0 && total == 1 -> "1 request, none ready yet"
|
||||
ready == 0 -> "$total requests, none ready yet"
|
||||
ready == total && total == 1 -> "1 request, ready to watch"
|
||||
ready == total -> "All $total ready to watch"
|
||||
else -> "$ready of $total ready to watch"
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a viewer's requests for display, preserving the server's newest-first order inside
|
||||
* each band.
|
||||
*
|
||||
* The server has already sorted by when somebody asked, and that order is the useful part —
|
||||
* re-sorting by title or by state inside a band would lose it. Empty bands are dropped
|
||||
* rather than drawn as a heading over nothing.
|
||||
*/
|
||||
fun <T> groupRequests(
|
||||
requests: List<T>,
|
||||
statusOf: (T) -> String,
|
||||
): List<Pair<RequestGroup, List<T>>> =
|
||||
RequestGroup.entries
|
||||
.map { group -> group to requests.filter { requestGroupFor(statusOf(it)) == group } }
|
||||
.filter { (_, items) -> items.isNotEmpty() }
|
||||
@@ -0,0 +1,730 @@
|
||||
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.search.TvKeyboard
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
|
||||
/**
|
||||
* Requests, in the TV calendar's clothes.
|
||||
*
|
||||
* The same gradient ground, the same header shape (accent glyph, eyebrow, title, a count on
|
||||
* the right), the same panel corners and the same cards. Two tabs rather than the calendar's
|
||||
* week switcher, because the page answers two questions: what did I ask for, and what else
|
||||
* can I ask for.
|
||||
*
|
||||
* **Focus contract**, which is the hard half:
|
||||
* - The tab strip is the page's entry point and its top row. Left from it is the rail.
|
||||
* - Down from the tabs enters the pane; Up from anywhere in a pane returns to the tabs.
|
||||
* - In the discover pane the keyboard's leftmost column goes to the rail and its rightmost
|
||||
* goes to the results, exactly as the Search tab's does — it is literally the same
|
||||
* keyboard.
|
||||
* - Back steps out one level per press: pane → tabs → leave the page.
|
||||
*
|
||||
* Nothing here can dead-end: a pane with no cards keeps its notice unfocusable and Up still
|
||||
* reaches the tabs, and every empty state that *can* act (retry, clear the query) puts a
|
||||
* focusable control in the pane rather than leaving the remote with nowhere to go.
|
||||
*/
|
||||
@Composable
|
||||
fun RequestsScreen(
|
||||
state: RequestsUiState,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
onSelectTab: (RequestsTab) -> Unit,
|
||||
onQueryChanged: (String) -> Unit,
|
||||
onAppendToQuery: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
onRemove: (GatewayMediaRequestItem) -> Unit,
|
||||
onOpenItem: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
posterUrlFor: (String) -> String? = { it.takeIf(String::isNotBlank) },
|
||||
) {
|
||||
val tabsFocusRequester = remember { FocusRequester() }
|
||||
val paneFocusRequester = remember { FocusRequester() }
|
||||
val keyboardEntry = remember { FocusRequester() }
|
||||
var paneFocused by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(32L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
// Back steps out one level per press, the stance every page here takes.
|
||||
BackHandler(enabled = paneFocused) {
|
||||
paneFocused = false
|
||||
runCatching { tabsFocusRequester.requestFocus() }
|
||||
}
|
||||
BackHandler(enabled = !paneFocused, onBack = onExit)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF070B0D), MembySurface, Color(0xFF07110D)),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(start = 38.dp, end = 30.dp, top = 22.dp, bottom = 22.dp),
|
||||
) {
|
||||
RequestsHeader(state)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RequestsTabStrip(
|
||||
selected = state.tab,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
paneFocusRequester = paneFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onSelectTab = {
|
||||
paneFocused = false
|
||||
onSelectTab(it)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
|
||||
when {
|
||||
// Permission withdrawn while the page was open. Said plainly, because an
|
||||
// empty list would read as "you have never asked for anything".
|
||||
!state.allowed -> RequestsNotice(
|
||||
icon = Icons.Default.Inbox,
|
||||
heading = "Requests are not available",
|
||||
body = "This profile is no longer allowed to request titles. Ask whoever looks after Memby.",
|
||||
)
|
||||
state.tab == RequestsTab.MINE -> MyRequestsPane(
|
||||
state = state,
|
||||
paneFocusRequester = paneFocusRequester,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onFocused = { paneFocused = true },
|
||||
onRemove = onRemove,
|
||||
onOpenItem = onOpenItem,
|
||||
onRetry = onRetry,
|
||||
onBrowse = { onSelectTab(RequestsTab.DISCOVER) },
|
||||
posterUrlFor = posterUrlFor,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
else -> DiscoverPane(
|
||||
state = state,
|
||||
keyboardEntry = keyboardEntry,
|
||||
paneFocusRequester = paneFocusRequester,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
onFocused = { paneFocused = true },
|
||||
onAppendToQuery = onAppendToQuery,
|
||||
onBackspace = onBackspace,
|
||||
onClearQuery = onClearQuery,
|
||||
onRequest = onRequest,
|
||||
posterUrlFor = posterUrlFor,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestsHeader(state: RequestsUiState) {
|
||||
val ready = state.requests.count { requestGroupFor(it.status) == RequestGroup.READY }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlaylistAdd, null,
|
||||
tint = MembyAccent, modifier = Modifier.size(21.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("REQUESTS", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Ask for something",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
// Only over the list it describes. Beside a pane of search results it is a count of
|
||||
// something else on screen, which reads as a caption for the wrong thing.
|
||||
if (!state.loadingRequests && state.tab == RequestsTab.MINE) {
|
||||
Text(
|
||||
requestSummaryLabel(state.requests.size, ready),
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestsTabStrip(
|
||||
selected: RequestsTab,
|
||||
tabsFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
paneFocusRequester: FocusRequester,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
onSelectTab: (RequestsTab) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
RequestsTab.entries.forEachIndexed { index, tab ->
|
||||
val isSelected = tab == selected
|
||||
FocusScaleContainer(
|
||||
// Focus is not selection here, unlike the detail page's tab strip: pressing
|
||||
// a tab reloads a pane and moves focus into it, so following the D-pad would
|
||||
// fire a request every time somebody passed over it on the way to the rail.
|
||||
onFocused = {},
|
||||
onClick = { onSelectTab(tab) },
|
||||
contentDescription = tab.label,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
// The selected tab is where the page opens and where Back returns
|
||||
// to, so both anchors hang off it rather than off a fixed index.
|
||||
if (isSelected) {
|
||||
Modifier
|
||||
.focusRequester(tabsFocusRequester)
|
||||
.focusRequester(contentFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
if (index == 0) left = navigationFocusRequester
|
||||
down = paneFocusRequester
|
||||
}
|
||||
.background(
|
||||
MembySurfaceRaised.copy(alpha = 0.62f),
|
||||
RoundedCornerShape(MembyChipCorner),
|
||||
),
|
||||
) { focused ->
|
||||
Row(
|
||||
Modifier
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
isSelected -> MembyAccent.copy(alpha = 0.18f)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
RoundedCornerShape(MembyChipCorner),
|
||||
)
|
||||
.then(
|
||||
if (isSelected && !focused) {
|
||||
Modifier.border(
|
||||
1.dp,
|
||||
MembyAccent.copy(alpha = 0.55f),
|
||||
RoundedCornerShape(MembyChipCorner),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
if (tab == RequestsTab.MINE) Icons.Default.Inbox else Icons.Default.Search,
|
||||
null,
|
||||
tint = if (focused) MembySurface else MembyAccent,
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
tab.label,
|
||||
color = if (focused) MembySurface else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyRequestsPane(
|
||||
state: RequestsUiState,
|
||||
paneFocusRequester: FocusRequester,
|
||||
tabsFocusRequester: FocusRequester,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
onRemove: (GatewayMediaRequestItem) -> Unit,
|
||||
onOpenItem: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onBrowse: () -> Unit,
|
||||
posterUrlFor: (String) -> String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (state.loadingRequests && state.requests.isEmpty()) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
repeat(4) { RequestCardSkeleton() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.requestsError != null && state.requests.isEmpty()) {
|
||||
RequestsNotice(
|
||||
icon = Icons.Default.Inbox,
|
||||
heading = "Could not load your requests",
|
||||
body = state.requestsError,
|
||||
action = "Try again",
|
||||
onAction = onRetry,
|
||||
actionFocusRequester = paneFocusRequester,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (state.requests.isEmpty()) {
|
||||
RequestsNotice(
|
||||
icon = Icons.Default.Inbox,
|
||||
heading = "You have not asked for anything yet",
|
||||
body = "Find a film or series and it will show up here while the household gets hold of it.",
|
||||
action = "Request something",
|
||||
onAction = onBrowse,
|
||||
actionFocusRequester = paneFocusRequester,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val groups = remember(state.requests) {
|
||||
groupRequests(state.requests) { it.status }
|
||||
}
|
||||
// The first card of the first group is the pane's entry point, so Down from the tabs
|
||||
// always lands somewhere real regardless of which band happens to be first today.
|
||||
val firstKey = groups.firstOrNull()?.second?.firstOrNull()
|
||||
?.let { candidateKey(it.mediaType, it.foreignId) }
|
||||
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
groups.forEach { (group, requests) ->
|
||||
item(key = "heading-${group.name}") {
|
||||
RequestGroupHeading(group.heading, requests.size)
|
||||
}
|
||||
items(requests, key = { candidateKey(it.mediaType, it.foreignId) }) { request ->
|
||||
val key = candidateKey(request.mediaType, request.foreignId)
|
||||
val watchable = request.status == RequestStatus.AVAILABLE &&
|
||||
request.embyItemId.isNotBlank()
|
||||
RequestCard(
|
||||
title = request.title,
|
||||
subtitle = listOfNotNull(
|
||||
request.year.takeIf { it > 0 }?.toString(),
|
||||
if (request.mediaType == "series") "Series" else "Film",
|
||||
).joinToString(" · "),
|
||||
detail = request.statusDetail.ifBlank {
|
||||
requestStatusLabel(request.status, request.statusLabel)
|
||||
},
|
||||
status = request.status,
|
||||
statusLabel = requestStatusLabel(request.status, request.statusLabel),
|
||||
mediaType = request.mediaType,
|
||||
artworkUrl = posterUrlFor(request.posterUrl),
|
||||
// A request that has arrived opens the thing it became; anything else
|
||||
// has nothing to play, so the press stops listing it instead.
|
||||
action = if (watchable) RequestActionPlay else RequestActionRemove,
|
||||
onFocused = onFocused,
|
||||
onClick = {
|
||||
if (watchable) onOpenItem(request.embyItemId) else onRemove(request)
|
||||
},
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (key == firstKey) {
|
||||
Modifier.focusRequester(paneFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
left = navigationFocusRequester
|
||||
if (key == firstKey) up = tabsFocusRequester
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestGroupHeading(heading: String, count: Int) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
heading.uppercase(),
|
||||
color = MembyAccent,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.6.sp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("$count", color = MembyQuietText, fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverPane(
|
||||
state: RequestsUiState,
|
||||
keyboardEntry: FocusRequester,
|
||||
paneFocusRequester: FocusRequester,
|
||||
tabsFocusRequester: FocusRequester,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
onAppendToQuery: (String) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onClearQuery: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
posterUrlFor: (String) -> String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val resultsEntry = remember { FocusRequester() }
|
||||
var lastKeyIndex by remember { mutableStateOf(0) }
|
||||
val hasResults = state.candidates.isNotEmpty()
|
||||
|
||||
Row(modifier) {
|
||||
Column(
|
||||
Modifier
|
||||
.width(340.dp)
|
||||
.fillMaxHeight()
|
||||
.onFocusChanged { if (it.hasFocus) onFocused() },
|
||||
) {
|
||||
QueryLine(state)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier
|
||||
// The keyboard is the pane's entry point and its top-left control, so
|
||||
// Down from the tabs lands on it and Up from it goes back.
|
||||
.focusRequester(paneFocusRequester)
|
||||
.focusProperties { up = tabsFocusRequester },
|
||||
) {
|
||||
TvKeyboard(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
keyboardReturn = keyboardEntry,
|
||||
lastKeyIndex = lastKeyIndex,
|
||||
hasResultsTarget = hasResults,
|
||||
onKeyFocused = { lastKeyIndex = it },
|
||||
onCharacter = onAppendToQuery,
|
||||
onBackspace = onBackspace,
|
||||
onClear = onClearQuery,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(20.dp))
|
||||
CandidatesPane(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardEntry = keyboardEntry,
|
||||
tabsFocusRequester = tabsFocusRequester,
|
||||
onFocused = onFocused,
|
||||
onRequest = onRequest,
|
||||
posterUrlFor = posterUrlFor,
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What has been typed, echoed above the keyboard the way the Search tab echoes it. */
|
||||
@Composable
|
||||
private fun QueryLine(state: RequestsUiState) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.34f), RoundedCornerShape(MembyCardCorner))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.06f), RoundedCornerShape(MembyCardCorner))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text("SEARCH", color = MembyAccent, fontSize = 9.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(
|
||||
state.query.ifBlank { "Type a film or series name" },
|
||||
color = if (state.query.isBlank()) MembyQuietText else Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CandidatesPane(
|
||||
state: RequestsUiState,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
tabsFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
posterUrlFor: (String) -> String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("RESULTS", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
when {
|
||||
state.query.isBlank() -> "What are you after?"
|
||||
!shouldLookup(state.query) -> "Keep typing…"
|
||||
state.searching -> "Looking…"
|
||||
else -> state.query.trim()
|
||||
},
|
||||
color = Color.White,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
state.notice?.let {
|
||||
Text(
|
||||
it,
|
||||
color = if (state.noticeIsError) Color(0xFFE0605A) else MembyAccent,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(9.dp))
|
||||
|
||||
if (state.searching && state.candidates.isEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
repeat(3) { RequestCardSkeleton() }
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
if (state.candidates.isEmpty()) {
|
||||
PaneMessage(
|
||||
heading = when {
|
||||
state.searchError != null -> "Search failed"
|
||||
state.query.isBlank() -> "Search for a film or series"
|
||||
!shouldLookup(state.query) -> "Type a little more"
|
||||
state.hasSearched -> "Nothing found"
|
||||
else -> "Search for a film or series"
|
||||
},
|
||||
body = state.searchError
|
||||
?: when {
|
||||
state.query.isBlank() ->
|
||||
"Use the keyboard to find something the household does not have yet."
|
||||
!shouldLookup(state.query) ->
|
||||
"At least ${RequestsViewModel.MIN_QUERY_LENGTH} characters, so the search has something to go on."
|
||||
state.hasSearched ->
|
||||
"Nothing matched “${state.query.trim()}”. Check the spelling, or try the original title."
|
||||
else -> "Use the keyboard to find something the household does not have yet."
|
||||
},
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
contentPadding = PaddingValues(bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(
|
||||
state.candidates,
|
||||
key = { candidateKey(it.mediaType, it.foreignId) },
|
||||
) { candidate ->
|
||||
val key = candidateKey(candidate.mediaType, candidate.foreignId)
|
||||
val actionable = requestCandidateActionable(candidate.status)
|
||||
RequestCard(
|
||||
title = candidate.title,
|
||||
subtitle = listOfNotNull(
|
||||
candidate.year.takeIf { it > 0 }?.toString(),
|
||||
if (candidate.mediaType == "series") "Series" else "Film",
|
||||
).joinToString(" · "),
|
||||
detail = candidateDetail(candidate),
|
||||
status = candidate.status,
|
||||
statusLabel = requestStatusLabel(candidate.status, candidate.statusLabel),
|
||||
mediaType = candidate.mediaType,
|
||||
artworkUrl = posterUrlFor(candidate.posterUrl),
|
||||
busy = key in state.submitting,
|
||||
action = if (actionable) RequestActionRequest else null,
|
||||
onFocused = onFocused,
|
||||
onClick = { if (actionable) onRequest(candidate) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (candidate == state.candidates.first()) {
|
||||
Modifier.focusRequester(resultsEntry)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
// Left always returns to the keyboard rather than falling
|
||||
// through to the rail: the keyboard is what this pane is for.
|
||||
left = keyboardEntry
|
||||
if (candidate == state.candidates.first()) up = tabsFocusRequester
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The line under a search result: why it is in this state, in plain words. */
|
||||
private fun candidateDetail(candidate: GatewayRequestCandidate): String = when (candidate.status) {
|
||||
RequestStatus.AVAILABLE -> "Already in your library"
|
||||
RequestStatus.REQUESTED -> "You have already asked for this"
|
||||
RequestStatus.PROCESSING -> "The household is already getting this"
|
||||
RequestStatus.PENDING -> "Already tracked, not out yet"
|
||||
RequestStatus.REQUESTABLE -> "Press to request"
|
||||
else -> candidate.statusLabel.ifBlank { "Press to request" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneMessage(heading: String, body: String) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(MembyPanelCorner)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 40.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Search, null, tint = MembyQuietText, modifier = Modifier.size(30.dp))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(heading, color = MembyMutedText, fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(body, color = MembyQuietText, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-pane notice, optionally with something to press.
|
||||
*
|
||||
* When it carries an action that control takes the pane's focus anchor, so Down from the
|
||||
* tabs still lands somewhere and Up from it still returns — an empty page must never be a
|
||||
* page the remote is stuck on.
|
||||
*/
|
||||
@Composable
|
||||
private fun RequestsNotice(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
heading: String,
|
||||
body: String,
|
||||
modifier: Modifier = Modifier,
|
||||
action: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
actionFocusRequester: FocusRequester? = null,
|
||||
tabsFocusRequester: FocusRequester? = null,
|
||||
navigationFocusRequester: FocusRequester? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(MembyPanelCorner))
|
||||
.padding(30.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(icon, null, tint = MembyQuietText, modifier = Modifier.size(32.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(heading, color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(body, color = MembyMutedText, fontSize = 14.sp)
|
||||
if (action != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onAction,
|
||||
contentDescription = action,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
actionFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier,
|
||||
)
|
||||
.focusProperties {
|
||||
tabsFocusRequester?.let { up = it }
|
||||
navigationFocusRequester?.let { left = it }
|
||||
}
|
||||
.background(MembySurfaceRaised, RoundedCornerShape(MembyChipCorner)),
|
||||
) { focused ->
|
||||
Text(
|
||||
action,
|
||||
color = if (focused) MembySurface else Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(MembyChipCorner),
|
||||
)
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Which half of the page is showing. */
|
||||
enum class RequestsTab(val label: String) {
|
||||
MINE("My requests"),
|
||||
DISCOVER("Request something"),
|
||||
}
|
||||
|
||||
data class RequestsUiState(
|
||||
val tab: RequestsTab = RequestsTab.MINE,
|
||||
/** The viewer's own asks, newest first, as the gateway grouped and stated them. */
|
||||
val requests: List<GatewayMediaRequestItem> = emptyList(),
|
||||
val loadingRequests: Boolean = true,
|
||||
val requestsError: String? = null,
|
||||
val query: String = "",
|
||||
val candidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
val searching: Boolean = false,
|
||||
val hasSearched: Boolean = false,
|
||||
val searchError: String? = null,
|
||||
/**
|
||||
* Which candidates have a request in flight, keyed the way the wire keys them. A set
|
||||
* rather than a single id because a viewer can walk down the pane pressing several
|
||||
* before the first answers, and each card has to show its own progress.
|
||||
*/
|
||||
val submitting: Set<String> = emptySet(),
|
||||
/** The last thing that happened, shown once as a quiet line rather than a dialog. */
|
||||
val notice: String? = null,
|
||||
val noticeIsError: Boolean = false,
|
||||
/**
|
||||
* False once the gateway says this viewer may not request. The page can be open when
|
||||
* that arrives — an operator can withdraw access at any moment — and an empty list is
|
||||
* not the same answer as "you are not allowed to ask".
|
||||
*/
|
||||
val allowed: Boolean = true,
|
||||
)
|
||||
|
||||
/** The wire's identity for a title: catalogue plus id. Used for in-flight bookkeeping. */
|
||||
internal fun candidateKey(mediaType: String, foreignId: Int): String = "$mediaType:$foreignId"
|
||||
|
||||
/**
|
||||
* Owns the two panes' state.
|
||||
*
|
||||
* The search half reuses the Search tab's pipeline exactly — `debounce` → `trim` →
|
||||
* `distinctUntilChanged` → `collectLatest` — and `collectLatest` is the load-bearing part
|
||||
* for the same reason it is there: it cancels the in-flight lookup, so a slow answer for a
|
||||
* prefix can never overwrite the results for what was typed after it. Radarr and Sonarr
|
||||
* lookups are live provider queries measured in seconds, so that race is not hypothetical
|
||||
* here; it is the ordinary case.
|
||||
*/
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
class RequestsViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(RequestsUiState())
|
||||
val state: StateFlow<RequestsUiState> = _state.asStateFlow()
|
||||
|
||||
private val queryFlow = MutableStateFlow("")
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
queryFlow
|
||||
.debounce(DEBOUNCE_MS)
|
||||
.map { it.trim() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { term -> runLookup(term) }
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun selectTab(tab: RequestsTab) {
|
||||
if (_state.value.tab == tab) return
|
||||
_state.update { it.copy(tab = tab, notice = null) }
|
||||
// Coming back to the list is when somebody wants to see whether the thing they just
|
||||
// asked for has moved on, so this is the refresh that matters most.
|
||||
if (tab == RequestsTab.MINE) refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = viewModelScope.launch {
|
||||
_state.update { it.copy(loadingRequests = true, requestsError = null) }
|
||||
runCatching { repository.getMyRequests() }
|
||||
.onSuccess { list ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
requests = list.requests,
|
||||
allowed = list.allowed,
|
||||
loadingRequests = false,
|
||||
requestsError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
_state.update {
|
||||
it.copy(
|
||||
loadingRequests = false,
|
||||
requestsError = friendlyEmbyError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueryChanged(query: String) {
|
||||
// The field updates immediately; only the lookup is debounced.
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = query,
|
||||
notice = null,
|
||||
// Results for the previous term are cleared as soon as the term changes.
|
||||
// Leaving them up under a new query is how somebody requests the wrong film.
|
||||
candidates = if (shouldLookup(query)) it.candidates else emptyList(),
|
||||
hasSearched = if (shouldLookup(query)) it.hasSearched else false,
|
||||
searchError = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = query
|
||||
}
|
||||
|
||||
fun appendToQuery(text: String) = onQueryChanged(_state.value.query + text)
|
||||
|
||||
fun backspace() = onQueryChanged(_state.value.query.dropLast(1))
|
||||
|
||||
fun clearQuery() {
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", candidates = emptyList(), searching = false,
|
||||
hasSearched = false, searchError = null, notice = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = ""
|
||||
}
|
||||
|
||||
private suspend fun runLookup(term: String) {
|
||||
if (!shouldLookup(term)) {
|
||||
_state.update { it.copy(searching = false, candidates = emptyList(), hasSearched = false) }
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(searching = true, searchError = null) }
|
||||
runCatching { repository.lookupMediaRequests(term) }
|
||||
.onSuccess { candidates ->
|
||||
// Guard against a response for a term the viewer has already typed past.
|
||||
// collectLatest cancels the coroutine, but a call already past its last
|
||||
// suspension point still completes — the calendar's requestedMonth rule.
|
||||
if (_state.value.query.trim() != term) return@onSuccess
|
||||
_state.update {
|
||||
it.copy(candidates = candidates, searching = false, hasSearched = true)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
if (_state.value.query.trim() != term) return@onFailure
|
||||
_state.update {
|
||||
it.copy(
|
||||
searching = false, hasSearched = true,
|
||||
searchError = friendlyEmbyError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a title.
|
||||
*
|
||||
* The card is marked in flight rather than the page, so the rest of the pane stays live
|
||||
* — a viewer who asked for one thing usually wants the next one too, and a page that
|
||||
* locks while Radarr thinks reads as broken.
|
||||
*/
|
||||
fun request(candidate: GatewayRequestCandidate) {
|
||||
val key = candidateKey(candidate.mediaType, candidate.foreignId)
|
||||
if (key in _state.value.submitting) return
|
||||
if (!requestCandidateActionable(candidate.status)) return
|
||||
_state.update { it.copy(submitting = it.submitting + key, notice = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.requestMedia(candidate) }
|
||||
.onSuccess { title ->
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
submitting = current.submitting - key,
|
||||
notice = "Requested ${title.ifBlank { candidate.title }}",
|
||||
noticeIsError = false,
|
||||
// Reflect it on the card immediately rather than waiting for a
|
||||
// re-lookup: the viewer pressed it, and a card that still says
|
||||
// "Request" is one they press again.
|
||||
candidates = current.candidates.map {
|
||||
if (candidateKey(it.mediaType, it.foreignId) == key) {
|
||||
it.copy(status = RequestStatus.REQUESTED, mine = true, statusLabel = "")
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// The list behind the other tab is now out of date.
|
||||
refresh()
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
_state.update {
|
||||
it.copy(
|
||||
submitting = it.submitting - key,
|
||||
notice = friendlyEmbyError(error),
|
||||
noticeIsError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listing one of the viewer's own requests.
|
||||
*
|
||||
* Removed from the list optimistically, the stance the alerts page takes: this is the
|
||||
* one page whose job is emptying itself, and a row that lingers under a thumb is a row
|
||||
* that gets pressed again.
|
||||
*/
|
||||
fun remove(request: GatewayMediaRequestItem) {
|
||||
val key = candidateKey(request.mediaType, request.foreignId)
|
||||
val previous = _state.value.requests
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
requests = current.requests.filterNot {
|
||||
candidateKey(it.mediaType, it.foreignId) == key
|
||||
},
|
||||
notice = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.removeMediaRequest(request.mediaType, request.foreignId) }
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
// Put it back. A request that silently stayed removed on screen while
|
||||
// the server still held it would reappear on the next refresh anyway,
|
||||
// which is more confusing than saying so now.
|
||||
_state.update {
|
||||
it.copy(
|
||||
requests = previous,
|
||||
notice = friendlyEmbyError(error),
|
||||
noticeIsError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotice() = _state.update { it.copy(notice = null) }
|
||||
|
||||
companion object {
|
||||
const val DEBOUNCE_MS = 300L
|
||||
|
||||
/**
|
||||
* Three characters, not the Search tab's two.
|
||||
*
|
||||
* Every keystroke here reaches Radarr and Sonarr rather than a local index, and a
|
||||
* two-letter prefix returns a hundred films nobody meant while costing two live
|
||||
* provider queries. The gateway independently refuses under two.
|
||||
*/
|
||||
const val MIN_QUERY_LENGTH = 3
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldLookup(query: String): Boolean =
|
||||
query.trim().length >= RequestsViewModel.MIN_QUERY_LENGTH
|
||||
|
||||
class RequestsViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
RequestsViewModel(repository) as T
|
||||
}
|
||||
@@ -492,8 +492,15 @@ private fun QueryField(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal rather than private so the Requests page shares this exact keyboard.
|
||||
*
|
||||
* Two on-screen keyboards in one app is two focus contracts to keep in step, and the one
|
||||
* thing a viewer must never have to relearn is where the letters are and what Left does at
|
||||
* the edge of them.
|
||||
*/
|
||||
@Composable
|
||||
private fun TvKeyboard(
|
||||
internal fun TvKeyboard(
|
||||
navigationFocusRequester: FocusRequester,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardEntry: FocusRequester,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import com.ponzischeme89.memby.ui.userSwitcherActionCount
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherDirection
|
||||
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RequestPresentationTest {
|
||||
|
||||
@Test
|
||||
fun `an unknown state is drawn quietly rather than borrowing a colour`() {
|
||||
// A state invented on the gateway must reach an older television as a neutral chip.
|
||||
// Giving it a tone would have it claim something this build cannot know.
|
||||
assertEquals(RequestTone.NEUTRAL, requestStatusTone("approved"))
|
||||
assertEquals(RequestTone.NEUTRAL, requestStatusTone(""))
|
||||
assertEquals(RequestTone.NEUTRAL, requestStatusTone(RequestStatus.UNAVAILABLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each meaningful state keeps its own tone`() {
|
||||
assertEquals(RequestTone.POSITIVE, requestStatusTone(RequestStatus.AVAILABLE))
|
||||
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.PROCESSING))
|
||||
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.REQUESTED))
|
||||
assertEquals(RequestTone.WAITING, requestStatusTone(RequestStatus.PENDING))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server's wording wins so a new state reads correctly on an old build`() {
|
||||
assertEquals("Awaiting approval", requestStatusLabel("approval_pending", "Awaiting approval"))
|
||||
// Only when the gateway sent nothing does the local table answer.
|
||||
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, ""))
|
||||
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, " "))
|
||||
// And a state with no local wording still says something rather than going blank.
|
||||
assertEquals("Requested", requestStatusLabel("approval_pending", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a title nobody has and nobody asked for can be pressed`() {
|
||||
assertTrue(requestCandidateActionable(RequestStatus.REQUESTABLE))
|
||||
assertFalse(requestCandidateActionable(RequestStatus.AVAILABLE))
|
||||
assertFalse(requestCandidateActionable(RequestStatus.PROCESSING))
|
||||
assertFalse(requestCandidateActionable(RequestStatus.PENDING))
|
||||
// Pressing an already-made request again would be a second identical ask.
|
||||
assertFalse(requestCandidateActionable(RequestStatus.REQUESTED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a gateway that predates the status field keeps the old press-anything behaviour`() {
|
||||
assertTrue(requestCandidateActionable(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `states collapse into the three bands somebody actually asks about`() {
|
||||
assertEquals(RequestGroup.READY, requestGroupFor(RequestStatus.AVAILABLE))
|
||||
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PROCESSING))
|
||||
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PENDING))
|
||||
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.REQUESTED))
|
||||
assertEquals(RequestGroup.CLOSED, requestGroupFor(RequestStatus.UNAVAILABLE))
|
||||
// An unknown state has nothing left to happen to it as far as this build knows.
|
||||
assertEquals(RequestGroup.CLOSED, requestGroupFor("approved"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `grouping keeps the server's order and drops empty bands`() {
|
||||
val requests = listOf(
|
||||
"b" to RequestStatus.PROCESSING,
|
||||
"a" to RequestStatus.AVAILABLE,
|
||||
"c" to RequestStatus.PENDING,
|
||||
"d" to RequestStatus.AVAILABLE,
|
||||
)
|
||||
val groups = groupRequests(requests) { it.second }
|
||||
|
||||
// Nothing is closed, so that heading is not drawn over an empty band.
|
||||
assertEquals(listOf(RequestGroup.READY, RequestGroup.IN_PROGRESS), groups.map { it.first })
|
||||
// Within a band the server's newest-first order survives: "a" was listed before "d",
|
||||
// and re-sorting alphabetically or by state would lose the only useful ordering.
|
||||
assertEquals(listOf("a", "d"), groups[0].second.map { it.first })
|
||||
assertEquals(listOf("b", "c"), groups[1].second.map { it.first })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the summary counts what arrived rather than the total`() {
|
||||
assertEquals("Nothing requested yet", requestSummaryLabel(0, 0))
|
||||
assertEquals("1 request, none ready yet", requestSummaryLabel(1, 0))
|
||||
assertEquals("4 requests, none ready yet", requestSummaryLabel(4, 0))
|
||||
assertEquals("1 request, ready to watch", requestSummaryLabel(1, 1))
|
||||
assertEquals("All 3 ready to watch", requestSummaryLabel(3, 3))
|
||||
assertEquals("2 of 5 ready to watch", requestSummaryLabel(5, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lookup threshold is stricter than the library search's`() {
|
||||
// Every keystroke here reaches Radarr and Sonarr rather than a local index.
|
||||
assertFalse(shouldLookup("du"))
|
||||
assertTrue(shouldLookup("dun"))
|
||||
assertFalse(shouldLookup(" a "))
|
||||
assertTrue(shouldLookup(" dune "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the switcher's action count matches the rows actually drawn`() {
|
||||
assertEquals(2, userSwitcherActionCount(showRequests = false))
|
||||
assertEquals(3, userSwitcherActionCount(showRequests = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
|
||||
// The bug this guards is the one that makes Manage users unreachable: an action
|
||||
// count that disagrees with the number of rows caps the D-pad one row short.
|
||||
val profiles = listOf("p1", "p2")
|
||||
val withRequests = userSwitcherActionCount(showRequests = true)
|
||||
var index = 0
|
||||
repeat(10) {
|
||||
index = userSwitcherNextIndex(
|
||||
currentIndex = index,
|
||||
profileCount = profiles.size,
|
||||
direction = UserSwitcherDirection.DOWN,
|
||||
actionCount = withRequests,
|
||||
)
|
||||
}
|
||||
assertEquals(profiles.size + withRequests - 1, index)
|
||||
|
||||
val withoutRequests = userSwitcherActionCount(showRequests = false)
|
||||
index = 0
|
||||
repeat(10) {
|
||||
index = userSwitcherNextIndex(
|
||||
currentIndex = index,
|
||||
profileCount = profiles.size,
|
||||
direction = UserSwitcherDirection.DOWN,
|
||||
actionCount = withoutRequests,
|
||||
)
|
||||
}
|
||||
assertEquals(profiles.size + withoutRequests - 1, index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.ponzischeme89.memby.ui.requests
|
||||
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.data.EmbyProfile
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders Requests to PNGs under `build/screenshots/requests/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*RequestsScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* The states worth looking at are the ones a unit test cannot describe: whether the status
|
||||
* badges are separable at television distance, whether a card carrying no artwork still
|
||||
* reads (Radarr and Sonarr posters are remote and frequently slow or absent), and whether
|
||||
* the skeletons look like the page that is about to arrive rather than like a fault.
|
||||
*
|
||||
* There is no network here — every URL is blank on purpose, so what these capture is the
|
||||
* artwork-free fallback, which is the case the calendar's own screenshot test exists for
|
||||
* too.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class RequestsScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
/** The ordinary page: every band occupied, so the three headings can be compared. */
|
||||
@Test
|
||||
fun `my requests across every state`() {
|
||||
capture("requests-mine", RequestsUiState(requests = sampleRequests, loadingRequests = false))
|
||||
}
|
||||
|
||||
/** Nothing asked for yet. The empty state has a control, so focus has somewhere to go. */
|
||||
@Test
|
||||
fun `nothing requested yet`() {
|
||||
capture("requests-mine-empty", RequestsUiState(loadingRequests = false))
|
||||
}
|
||||
|
||||
/** The first load. Skeletons rather than a spinner, in the shape of the real cards. */
|
||||
@Test
|
||||
fun `loading the first list`() {
|
||||
capture("requests-mine-loading", RequestsUiState(loadingRequests = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the list could not be loaded`() {
|
||||
capture(
|
||||
"requests-mine-error",
|
||||
RequestsUiState(
|
||||
loadingRequests = false,
|
||||
requestsError = "Memby could not reach the server. Check the connection and try again.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Search results, with every state a candidate can be in showing at once. */
|
||||
@Test
|
||||
fun `search results`() {
|
||||
capture(
|
||||
"requests-discover",
|
||||
RequestsUiState(
|
||||
tab = RequestsTab.DISCOVER,
|
||||
loadingRequests = false,
|
||||
query = "dune",
|
||||
hasSearched = true,
|
||||
candidates = sampleCandidates,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** A request in flight: only the pressed card shows it, the rest of the pane stays live. */
|
||||
@Test
|
||||
fun `a request being submitted`() {
|
||||
capture(
|
||||
"requests-discover-submitting",
|
||||
RequestsUiState(
|
||||
tab = RequestsTab.DISCOVER,
|
||||
loadingRequests = false,
|
||||
query = "dune",
|
||||
hasSearched = true,
|
||||
candidates = sampleCandidates,
|
||||
submitting = setOf(candidateKey("movie", 693134)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The keyboard with nothing typed. This is the pane's opening frame on every visit. */
|
||||
@Test
|
||||
fun `discover before anything is typed`() {
|
||||
capture(
|
||||
"requests-discover-empty",
|
||||
RequestsUiState(tab = RequestsTab.DISCOVER, loadingRequests = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a search that found nothing`() {
|
||||
capture(
|
||||
"requests-discover-no-matches",
|
||||
RequestsUiState(
|
||||
tab = RequestsTab.DISCOVER,
|
||||
loadingRequests = false,
|
||||
query = "qqqqq",
|
||||
hasSearched = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission withdrawn while the page was open. Worth a capture because an empty list
|
||||
* and "you are not allowed to ask" must not look like the same page.
|
||||
*/
|
||||
@Test
|
||||
fun `requests no longer permitted`() {
|
||||
capture(
|
||||
"requests-not-allowed",
|
||||
RequestsUiState(loadingRequests = false, allowed = false),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The way in. Captured beside the page because the entry and the permission are one
|
||||
* feature: this pair is what shows whether the entry is hidden for somebody who may not
|
||||
* request, rather than dimmed.
|
||||
*/
|
||||
@Test
|
||||
fun `user menu offering requests`() {
|
||||
capturePicker("requests-user-menu", showRequests = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user menu for a viewer who may not request`() {
|
||||
capturePicker("requests-user-menu-hidden", showRequests = false)
|
||||
}
|
||||
|
||||
private fun capturePicker(name: String, showRequests: Boolean) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
UserSwitcherOverlay(
|
||||
profiles = listOf(
|
||||
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
|
||||
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
|
||||
),
|
||||
activeProfileId = "a",
|
||||
onProfileSelected = {},
|
||||
onManageProfiles = {},
|
||||
onDismiss = {},
|
||||
alertCount = 2,
|
||||
onOpenAlerts = {},
|
||||
showRequests = showRequests,
|
||||
onOpenRequests = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, state: RequestsUiState) {
|
||||
compose.setContent {
|
||||
MembyTheme {
|
||||
RequestsScreen(
|
||||
state = state,
|
||||
navigationFocusRequester = FocusRequester(),
|
||||
contentFocusRequester = FocusRequester(),
|
||||
onSelectTab = {},
|
||||
onQueryChanged = {},
|
||||
onAppendToQuery = {},
|
||||
onBackspace = {},
|
||||
onClearQuery = {},
|
||||
onRequest = {},
|
||||
onRemove = {},
|
||||
onOpenItem = {},
|
||||
onRetry = {},
|
||||
onExit = {},
|
||||
// No network in a screenshot test: this is the artwork-free fallback,
|
||||
// which is the case worth looking at anyway.
|
||||
posterUrlFor = { null },
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
|
||||
}
|
||||
}
|
||||
|
||||
private val sampleRequests = listOf(
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
|
||||
requestedAt = "2026-08-02T19:20:00Z", status = RequestStatus.AVAILABLE,
|
||||
statusLabel = "Available", statusDetail = "Ready to watch now", embyItemId = "emby-1",
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "series", foreignId = 121361, title = "Silo", year = 2023,
|
||||
requestedAt = "2026-08-01T08:05:00Z", status = RequestStatus.PROCESSING,
|
||||
statusLabel = "Processing", statusDetail = "Searching for a copy",
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "movie", foreignId = 533535, title = "The Thursday Murder Club", year = 2026,
|
||||
requestedAt = "2026-07-28T21:44:00Z", status = RequestStatus.PENDING,
|
||||
statusLabel = "Pending", statusDetail = "Waiting for release",
|
||||
),
|
||||
GatewayMediaRequestItem(
|
||||
mediaType = "series", foreignId = 94997, title = "A Show Nobody Is Tracking Any More",
|
||||
year = 2022, requestedAt = "2026-06-11T12:00:00Z", status = RequestStatus.UNAVAILABLE,
|
||||
statusLabel = "Unavailable", statusDetail = "No longer being tracked",
|
||||
),
|
||||
)
|
||||
|
||||
private val sampleCandidates = listOf(
|
||||
GatewayRequestCandidate(
|
||||
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
|
||||
status = RequestStatus.REQUESTABLE, statusLabel = "Request",
|
||||
),
|
||||
GatewayRequestCandidate(
|
||||
mediaType = "movie", foreignId = 438631, title = "Dune", year = 2021,
|
||||
status = RequestStatus.AVAILABLE, statusLabel = "Available", inLibrary = true,
|
||||
),
|
||||
GatewayRequestCandidate(
|
||||
mediaType = "series", foreignId = 331138, title = "Dune: Prophecy", year = 2024,
|
||||
status = RequestStatus.REQUESTED, statusLabel = "Requested", mine = true,
|
||||
),
|
||||
GatewayRequestCandidate(
|
||||
mediaType = "movie", foreignId = 841598, title = "Dune: Part Three", year = 2027,
|
||||
status = RequestStatus.PENDING, statusLabel = "Pending", alreadyAdded = true,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user