0.3.01
This commit is contained in:
@@ -90,6 +90,7 @@ class MaintenanceMonitor(
|
||||
|
||||
private val _preferencesRevision = MutableStateFlow(0L)
|
||||
private val _metadataHeroContentOrder = MutableStateFlow(DEFAULT_METADATA_HERO_CONTENT_ORDER)
|
||||
private val _metadataHeroTimeRemainingColour = MutableStateFlow(METADATA_HERO_TIME_COLOUR_GREEN)
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _heroRevision = MutableStateFlow("")
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
@@ -134,6 +135,8 @@ class MaintenanceMonitor(
|
||||
|
||||
/** One household-wide composition, delivered by the status poll to every viewer. */
|
||||
val metadataHeroContentOrder: StateFlow<List<String>> = _metadataHeroContentOrder.asStateFlow()
|
||||
val metadataHeroTimeRemainingColour: StateFlow<String> =
|
||||
_metadataHeroTimeRemainingColour.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the operator wants TVs that cannot install their own updates to be asked for
|
||||
@@ -188,18 +191,18 @@ class MaintenanceMonitor(
|
||||
*/
|
||||
val requestsAllowed: StateFlow<Boolean> = _requestsAllowed.asStateFlow()
|
||||
|
||||
/** Build reported by the connected gateway, for Settings → About. */
|
||||
/** Build reported by the connected gateway, for the Settings rail. */
|
||||
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
|
||||
|
||||
/**
|
||||
* The Emby build the gateway is talking to, printed beside the gateway's own on
|
||||
* Settings → About. Blank whenever it is not known — no probe has answered yet, the
|
||||
* probe is switched off, or the gateway predates the field — and About then prints the
|
||||
* Settings rail. Blank whenever it is not known — no probe has answered yet, the
|
||||
* probe is switched off, or the gateway predates the field — and the rail then prints the
|
||||
* gateway's version alone rather than an empty pair of brackets.
|
||||
*
|
||||
* Deliberately *not* cleared when the gateway drops out, unlike [gatewayVersion]. That
|
||||
* one describes a connection that is now gone; this describes a server that is still
|
||||
* whatever version it was, and About is exactly the page somebody opens when something
|
||||
* whatever version it was, and Settings is exactly where somebody looks when something
|
||||
* has stopped working.
|
||||
*/
|
||||
val embyVersion: StateFlow<String> = _embyVersion.asStateFlow()
|
||||
@@ -283,8 +286,9 @@ class MaintenanceMonitor(
|
||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -323,6 +327,12 @@ class MaintenanceMonitor(
|
||||
.filter { it in METADATA_HERO_CONTENT_OPTIONS }
|
||||
.distinct()
|
||||
.ifEmpty { DEFAULT_METADATA_HERO_CONTENT_ORDER }
|
||||
_metadataHeroTimeRemainingColour.value =
|
||||
status.metadataHeroTimeRemainingColour
|
||||
.trim()
|
||||
.lowercase()
|
||||
.takeIf { it == METADATA_HERO_TIME_COLOUR_WHITE }
|
||||
?: METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = status.theme
|
||||
_heroRevision.value = status.hero.revision
|
||||
_installPermissionPrompt.value =
|
||||
@@ -339,7 +349,7 @@ class MaintenanceMonitor(
|
||||
// should not have that fact disappear from the poll.
|
||||
_embyOutage.value = outageFrom(status.emby)
|
||||
// Beside the outage, and for the same reason: reported even
|
||||
// during maintenance, since an operator looking at About while
|
||||
// during maintenance, since an operator looking at Settings while
|
||||
// Memby is down still wants to know what Emby is running.
|
||||
status.emby.version.takeIf(String::isNotBlank)?.let {
|
||||
_embyVersion.value = it
|
||||
@@ -367,8 +377,9 @@ class MaintenanceMonitor(
|
||||
_embyOutage.value = null
|
||||
_preferencesRevision.value = 0
|
||||
_metadataHeroContentOrder.value = DEFAULT_METADATA_HERO_CONTENT_ORDER
|
||||
_metadataHeroTimeRemainingColour.value = METADATA_HERO_TIME_COLOUR_GREEN
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_heroRevision.value = ""
|
||||
_heroRevision.value = ""
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
@@ -475,6 +486,8 @@ class MaintenanceMonitor(
|
||||
listOf("title", "ratings", "facts", "summary")
|
||||
internal val METADATA_HERO_CONTENT_OPTIONS =
|
||||
DEFAULT_METADATA_HERO_CONTENT_ORDER + listOf("recommendation_reason", "time_remaining")
|
||||
internal const val METADATA_HERO_TIME_COLOUR_GREEN = "green"
|
||||
internal const val METADATA_HERO_TIME_COLOUR_WHITE = "white"
|
||||
internal const val POLL_INTERVAL_MS = 10_000L
|
||||
internal const val RATE_LIMIT_FALLBACK_MS = 60_000L
|
||||
internal const val MAX_RATE_LIMIT_SECONDS = 3_600L
|
||||
|
||||
@@ -237,6 +237,8 @@ data class GatewayServiceStatus(
|
||||
val preferencesRevision: Long = 0,
|
||||
/** Household-wide order of the focused metadata hero's content blocks. */
|
||||
val metadataHeroContentOrder: List<String> = emptyList(),
|
||||
/** Household-wide colour for the optional time-remaining caption: green or white. */
|
||||
val metadataHeroTimeRemainingColour: String = "green",
|
||||
/**
|
||||
* The colour scheme this viewer's televisions should be painted, as an id and a
|
||||
* revision rather than the palette itself — the [preferencesRevision] precedent, for
|
||||
@@ -317,9 +319,9 @@ data class GatewayEmbyHealth(
|
||||
val checkedAt: String = "",
|
||||
val retrySeconds: Int = 0,
|
||||
/**
|
||||
* Emby's own version, for the About page. Blank when no probe has answered yet, when the
|
||||
* Emby's own version, for the Settings rail. Blank when no probe has answered yet, when the
|
||||
* probe is switched off, and on a gateway that predates the field — all three of which
|
||||
* the page renders the same way, by printing the gateway's version alone.
|
||||
* rail renders the same way, by printing the gateway's version alone.
|
||||
*/
|
||||
val version: String = "",
|
||||
) {
|
||||
|
||||
@@ -284,6 +284,7 @@ internal fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) {
|
||||
internal fun FocusedHomeMetadata(
|
||||
homeViewModel: HomeViewModel,
|
||||
metadataHeroContentOrder: List<String>,
|
||||
metadataHeroTimeRemainingColour: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle()
|
||||
@@ -295,6 +296,7 @@ internal fun FocusedHomeMetadata(
|
||||
item = focusedItem,
|
||||
loading = homeContent.loading.isNotEmpty(),
|
||||
contentOrder = metadataHeroContentOrder,
|
||||
timeRemainingColour = metadataHeroTimeRemainingColour,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,8 @@ internal fun HomeScreen(
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val metadataHeroContentOrder by
|
||||
ServiceLocator.maintenance.metadataHeroContentOrder.collectAsStateWithLifecycle()
|
||||
val metadataHeroTimeRemainingColour by
|
||||
ServiceLocator.maintenance.metadataHeroTimeRemainingColour.collectAsStateWithLifecycle()
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||
@@ -286,9 +288,6 @@ internal fun HomeScreen(
|
||||
var quickMenuTrailerAvailable by remember { mutableStateOf(false) }
|
||||
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
|
||||
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
|
||||
var rowBrowseTarget by remember { mutableStateOf<HomeRowBrowseTarget?>(null) }
|
||||
var rowBrowseOriginRowId by remember { mutableStateOf<String?>(null) }
|
||||
var rowBrowseOriginItemId by remember { mutableStateOf<String?>(null) }
|
||||
var sectionHeroRows by remember(settings.userId) {
|
||||
mutableStateOf<Map<BrowseDestination, List<HomeRow>>>(emptyMap())
|
||||
}
|
||||
@@ -847,7 +846,6 @@ internal fun HomeScreen(
|
||||
)
|
||||
LaunchedEffect(selectedDestination) {
|
||||
focusedHomeRowId = null
|
||||
rowBrowseTarget = null
|
||||
}
|
||||
LaunchedEffect(selectedDestination, settings.forYouMinutes) {
|
||||
if (
|
||||
@@ -868,7 +866,6 @@ internal fun HomeScreen(
|
||||
showNotifications -> "notifications"
|
||||
userSwitcherVisible || showProfiles -> "profiles"
|
||||
detailsItem != null -> "details"
|
||||
rowBrowseTarget != null -> "row_browse"
|
||||
selectedMyShow != null -> "my_show_details"
|
||||
else -> selectedDestination.name.lowercase()
|
||||
}
|
||||
@@ -941,7 +938,6 @@ internal fun HomeScreen(
|
||||
navigationExpanded = it
|
||||
},
|
||||
onDestinationSelected = { destination ->
|
||||
rowBrowseTarget = null
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "select",
|
||||
screen = journeyScreen, feature = destination.name.lowercase(),
|
||||
@@ -1057,53 +1053,6 @@ internal fun HomeScreen(
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
rowBrowseTarget?.let { browseTarget ->
|
||||
GenreBrowseScreen(
|
||||
itemType = browseTarget.itemType,
|
||||
initialCategoryId = browseTarget.categoryId,
|
||||
favouriteStates = favoriteChanges,
|
||||
playedStates = playedChanges,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
returnFocusItemId = returnItemId.takeIf {
|
||||
returnRowId == ROW_BROWSE_RESULTS_ID
|
||||
},
|
||||
returnFocusRequester = cardReturnFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
returnRowId = ROW_BROWSE_RESULTS_ID
|
||||
returnRowKind = null
|
||||
returnItemId = item.id
|
||||
homeViewModel.focusItem(item)
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "row_browse",
|
||||
feature = "view_all", source = "row_browse_results",
|
||||
target = "details", itemName = item.name, itemType = item.type,
|
||||
)
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onClose = {
|
||||
rowBrowseTarget = null
|
||||
returnRowId = rowBrowseOriginRowId
|
||||
returnRowKind = rows.firstOrNull {
|
||||
it.id == rowBrowseOriginRowId
|
||||
}?.kind?.name
|
||||
returnItemId = rowBrowseOriginItemId
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
if (rowBrowseOriginItemId != null) {
|
||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||
} else {
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
if (selectedDestination == BrowseDestination.SEARCH) {
|
||||
// Remembered, or this list is a fresh instance on every recomposition
|
||||
// and the screen re-derives its genre chips each time.
|
||||
@@ -1432,6 +1381,7 @@ internal fun HomeScreen(
|
||||
FocusedHomeMetadata(
|
||||
homeViewModel = homeViewModel,
|
||||
metadataHeroContentOrder = metadataHeroContentOrder,
|
||||
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
|
||||
modifier = Modifier
|
||||
.height(metadataHeight)
|
||||
// LazyColumn is drawn later as a sibling. Keep the hero
|
||||
@@ -1565,7 +1515,6 @@ internal fun HomeScreen(
|
||||
row.items.take(8).map { it.id },
|
||||
)
|
||||
}
|
||||
val browseTarget = homeRowBrowseTarget(row)
|
||||
MediaRow(
|
||||
modifier = Modifier,
|
||||
row = row,
|
||||
@@ -1727,24 +1676,6 @@ internal fun HomeScreen(
|
||||
horizontalState = horizontalStates.getOrPut(
|
||||
"${selectedDestination.name}:${row.id}",
|
||||
) { LazyListState() },
|
||||
viewAllLabel = browseTarget?.actionLabel,
|
||||
onViewAll = browseTarget?.let { target ->
|
||||
{
|
||||
rowBrowseOriginRowId = row.id
|
||||
rowBrowseOriginItemId = returnItemId.takeIf {
|
||||
returnRowId == row.id
|
||||
} ?: row.items.getOrNull(
|
||||
rowFocusPositions[row.id] ?: 0,
|
||||
)?.id
|
||||
rowBrowseTarget = target
|
||||
homeViewModel.trackJourney(
|
||||
category = "navigation", action = "open",
|
||||
screen = selectedDestination.name.lowercase(),
|
||||
feature = "view_all", source = row.id,
|
||||
target = "row_browse",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ internal fun MetadataHero(
|
||||
item: BaseItem?,
|
||||
loading: Boolean,
|
||||
contentOrder: List<String> = emptyList(),
|
||||
timeRemainingColour: String = "green",
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
@@ -71,6 +72,7 @@ internal fun MetadataHero(
|
||||
item = item,
|
||||
loading = loading,
|
||||
contentOrder = contentOrder,
|
||||
timeRemainingColour = timeRemainingColour,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
|
||||
@@ -104,7 +104,7 @@ internal fun episodeLabel(season: Int?, episode: Int?, name: String?): String? {
|
||||
null
|
||||
}
|
||||
return when {
|
||||
code != null && cleanName.isNotEmpty() -> "$code - $cleanName"
|
||||
code != null && cleanName.isNotEmpty() -> "$code · $cleanName"
|
||||
code != null -> code
|
||||
cleanName.isNotEmpty() -> cleanName
|
||||
else -> null
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.weight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -51,7 +50,6 @@ import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -134,6 +132,7 @@ fun MediaMetadataPanel(
|
||||
item: BaseItem?,
|
||||
loading: Boolean,
|
||||
contentOrder: List<String>,
|
||||
timeRemainingColour: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
@@ -157,7 +156,13 @@ fun MediaMetadataPanel(
|
||||
Text("Choose something to watch.", color = MutedText, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
else -> MetadataContent(item, contentWidth, compactLayout, contentOrder)
|
||||
else -> MetadataContent(
|
||||
item,
|
||||
contentWidth,
|
||||
compactLayout,
|
||||
contentOrder,
|
||||
timeRemainingColour,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +206,7 @@ private fun MetadataContent(
|
||||
contentWidth: Dp,
|
||||
compact: Boolean,
|
||||
contentOrder: List<String>,
|
||||
timeRemainingColour: String,
|
||||
) {
|
||||
if (item.isSchedule) {
|
||||
ScheduleMetadataContent(item, contentWidth, compact)
|
||||
@@ -271,7 +277,7 @@ private fun MetadataContent(
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item)
|
||||
MetadataHeroSection.TimeRemaining -> MetadataTimeRemaining(item, timeRemainingColour)
|
||||
MetadataHeroSection.Summary -> Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = MembyMutedText, fontSize = 14.sp, lineHeight = 18.sp,
|
||||
@@ -336,13 +342,17 @@ private fun MetadataHeroTitle(item: BaseItem, logoUrl: String?, logo: String?, c
|
||||
fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
if (item.isEpisode && item.seriesName != null) Text(
|
||||
item.episodeCode
|
||||
?.filter { it.isLetterOrDigit() }
|
||||
?.lowercase(Locale.ROOT)
|
||||
.orEmpty(),
|
||||
color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.isEpisode) {
|
||||
episodeLabel(item.parentIndexNumber, item.indexNumber, item.name)?.let { label ->
|
||||
Text(
|
||||
label,
|
||||
color = MutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -434,7 +444,7 @@ private fun MetadataStatus(item: BaseItem) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetadataTimeRemaining(item: BaseItem) {
|
||||
private fun MetadataTimeRemaining(item: BaseItem, colour: String) {
|
||||
val position = item.userData?.playbackPositionTicks ?: 0L
|
||||
val runtime = item.runTimeTicks ?: 0L
|
||||
val minutes = metadataHeroMinutesRemaining(item) ?: return
|
||||
@@ -453,15 +463,19 @@ private fun MetadataTimeRemaining(item: BaseItem) {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (minutes == 1) "1 MINUTE REMAINING" else "$minutes MINUTES REMAINING",
|
||||
color = MutedText,
|
||||
metadataHeroTimeRemainingLabel(minutes),
|
||||
color = if (colour.equals("white", ignoreCase = true)) Color.White else EmbyGreen,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun metadataHeroTimeRemainingLabel(minutes: Long): String =
|
||||
if (minutes == 1L) "1 minute remaining" else "$minutes minutes remaining"
|
||||
|
||||
internal fun metadataHeroMinutesRemaining(item: BaseItem): Long? {
|
||||
val position = item.userData?.playbackPositionTicks ?: return null
|
||||
val runtime = item.runTimeTicks ?: return null
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -22,23 +21,17 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -54,8 +47,6 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
|
||||
@@ -175,20 +166,10 @@ internal fun MediaRow(
|
||||
density: String = "standard",
|
||||
artworkStyle: String = "automatic",
|
||||
horizontalState: LazyListState? = null,
|
||||
viewAllLabel: String? = null,
|
||||
onViewAll: (() -> Unit)? = null,
|
||||
) {
|
||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||
val rowState = horizontalState ?: savedRowState
|
||||
val verticalEntryFocusRequester = remember { FocusRequester() }
|
||||
val previousPageFocusRequester = remember { FocusRequester() }
|
||||
val nextPageFocusRequester = remember { FocusRequester() }
|
||||
val viewAllFocusRequester = remember { FocusRequester() }
|
||||
val headerReturnFocusRequester = remember { FocusRequester() }
|
||||
val pagedCardFocusRequester = remember { FocusRequester() }
|
||||
var headerReturnItemId by remember(row.id) { mutableStateOf<String?>(null) }
|
||||
var pagedCardIndex by remember(row.id) { mutableStateOf<Int?>(null) }
|
||||
var pageFocusRequestId by remember(row.id) { mutableIntStateOf(0) }
|
||||
val requestedEntryIndex = verticalFocusRequest
|
||||
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||
?.itemIndex
|
||||
@@ -216,39 +197,6 @@ internal fun MediaRow(
|
||||
}
|
||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||
}
|
||||
LaunchedEffect(pageFocusRequestId) {
|
||||
val target = pagedCardIndex ?: return@LaunchedEffect
|
||||
rowState.scrollToItem(target)
|
||||
// The target can begin outside the composed LazyRow window. Give layout a frame to
|
||||
// attach it, then keep the transfer bounded so a refreshed row can never trap focus.
|
||||
repeat(6) {
|
||||
delay(16.milliseconds)
|
||||
if (pagedCardFocusRequester.requestFocusIfAttached()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
// firstVisibleItemIndex changes on every scroll frame; read it through derivedStateOf so
|
||||
// only the button's enabled/disabled flip recomposes the row header.
|
||||
val canScrollBack by remember(rowState) {
|
||||
derivedStateOf { rowState.canScrollBackward }
|
||||
}
|
||||
val canScrollForward by remember(rowState) {
|
||||
derivedStateOf { rowState.canScrollForward }
|
||||
}
|
||||
val pageSize = if (
|
||||
row.items.firstOrNull()?.let { cardFormat(row.kind, it, artworkStyle) } == MediaCardFormat.PORTRAIT
|
||||
) 6 else 4
|
||||
val hasViewAll = viewAllLabel != null && onViewAll != null
|
||||
val headerReturnTargetId = headerReturnItemId
|
||||
?.takeIf { focusedId -> row.items.any { it.id == focusedId } }
|
||||
?: row.items.firstOrNull()?.id
|
||||
fun requestHeaderFocus(): Boolean = when {
|
||||
hasViewAll -> viewAllFocusRequester.requestFocusIfAttached()
|
||||
canScrollBack -> previousPageFocusRequester.requestFocusIfAttached()
|
||||
canScrollForward -> nextPageFocusRequester.requestFocusIfAttached()
|
||||
else -> false
|
||||
}
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -266,42 +214,6 @@ internal fun MediaRow(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (row.items.isNotEmpty()) {
|
||||
if (viewAllLabel != null && onViewAll != null) {
|
||||
ViewAllButton(
|
||||
label = viewAllLabel,
|
||||
focusRequester = viewAllFocusRequester,
|
||||
downFocusRequester = headerReturnFocusRequester,
|
||||
onClick = onViewAll,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
GalleryJumpButton(
|
||||
forward = false,
|
||||
enabled = canScrollBack,
|
||||
focusRequester = previousPageFocusRequester,
|
||||
downFocusRequester = headerReturnFocusRequester,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0)
|
||||
pagedCardIndex = target
|
||||
pageFocusRequestId += 1
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GalleryJumpButton(
|
||||
forward = true,
|
||||
enabled = canScrollForward,
|
||||
focusRequester = nextPageFocusRequester,
|
||||
downFocusRequester = headerReturnFocusRequester,
|
||||
onClick = {
|
||||
val target = (rowState.firstVisibleItemIndex + pageSize)
|
||||
.coerceAtMost(row.items.lastIndex)
|
||||
pagedCardIndex = target
|
||||
pageFocusRequestId += 1
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
row.items.isEmpty() && row.loading -> {
|
||||
@@ -369,52 +281,22 @@ internal fun MediaRow(
|
||||
if (item.id == returnFocusItemId) {
|
||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
if (item.id == headerReturnTargetId) {
|
||||
cardModifier = cardModifier.focusRequester(headerReturnFocusRequester)
|
||||
}
|
||||
if (index == requestedEntryIndex) {
|
||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
||||
}
|
||||
if (index == pagedCardIndex) {
|
||||
cardModifier = cardModifier.focusRequester(pagedCardFocusRequester)
|
||||
}
|
||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyDown) {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
requestHeaderFocus() ||
|
||||
onMoveVertical(index, RowFocusDirection.UP)
|
||||
onMoveVertical(index, RowFocusDirection.UP)
|
||||
}
|
||||
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
|
||||
Key.DirectionLeft -> {
|
||||
val firstVisible = rowState.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull()?.index
|
||||
if (canScrollBack && index == firstVisible) {
|
||||
previousPageFocusRequester.requestFocusIfAttached()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
val lastVisible = rowState.layoutInfo.visibleItemsInfo
|
||||
.lastOrNull()?.index
|
||||
if (index != lastVisible) {
|
||||
false
|
||||
} else if (viewAllLabel != null && onViewAll != null) {
|
||||
viewAllFocusRequester.requestFocusIfAttached()
|
||||
} else if (canScrollForward) {
|
||||
nextPageFocusRequester.requestFocusIfAttached()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
val focused: () -> Unit = {
|
||||
headerReturnItemId = item.id
|
||||
onContentFocused()
|
||||
// LazyRow already knows the semantic position. Passing it on
|
||||
// avoids searching the row again on every D-pad focus move.
|
||||
@@ -453,105 +335,6 @@ internal fun MediaRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryJumpButton(
|
||||
forward: Boolean,
|
||||
enabled: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
downFocusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { if (enabled) onClick() },
|
||||
onLongClick = null,
|
||||
contentDescription = if (forward) "Next page" else "Previous page",
|
||||
modifier = Modifier
|
||||
.width(42.dp)
|
||||
.height(36.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties {
|
||||
canFocus = enabled
|
||||
down = downFocusRequester
|
||||
},
|
||||
) { focused ->
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(
|
||||
when {
|
||||
focused -> MembyAccent.copy(alpha = 0.34f)
|
||||
enabled -> Color.White.copy(alpha = 0.08f)
|
||||
else -> Color.White.copy(alpha = 0.03f)
|
||||
},
|
||||
)
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(MembyChipCorner),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (forward) MembyIcon.ChevronRight.mark else MembyIcon.ChevronLeft.mark,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f),
|
||||
modifier = Modifier.size(23.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewAllButton(
|
||||
label: String,
|
||||
focusRequester: FocusRequester,
|
||||
downFocusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClick,
|
||||
contentDescription = label,
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { down = downFocusRequester },
|
||||
) { focused ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.clip(RoundedCornerShape(MembyChipCorner))
|
||||
.background(
|
||||
if (focused) MembyAccent.copy(alpha = 0.34f)
|
||||
else Color.White.copy(alpha = 0.08f),
|
||||
)
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) MembyAccent else Color.White.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(MembyChipCorner),
|
||||
)
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Icon(
|
||||
imageVector = MembyIcon.ChevronRight.mark,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun FocusRequester.requestFocusIfAttached(): Boolean =
|
||||
runCatching { requestFocus() }.getOrDefault(false)
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID
|
||||
import com.ponzischeme89.memby.ui.genre.genreCategoryTabs
|
||||
|
||||
internal const val ROW_BROWSE_RESULTS_ID = "row-browse-results"
|
||||
|
||||
/** The existing paged catalogue screen a home shelf can expand into. */
|
||||
internal data class HomeRowBrowseTarget(
|
||||
val categoryId: String,
|
||||
val itemType: String,
|
||||
val actionLabel: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves only shelves whose complete catalogue query is known on both backend paths.
|
||||
*
|
||||
* Recommendation and studio shelves deliberately return null: the horizontal row is a
|
||||
* ranked answer, while the catalogue browser can currently page only media types and
|
||||
* genres. Labelling an unfiltered library as "all Pixar" would be worse than omitting the
|
||||
* action until that filter exists.
|
||||
*/
|
||||
internal fun homeRowBrowseTarget(row: HomeBrowseRow): HomeRowBrowseTarget? {
|
||||
if (row.id == "latest-movies") {
|
||||
return HomeRowBrowseTarget(
|
||||
categoryId = ALL_MEDIA_CATEGORY_ID,
|
||||
itemType = "Movie",
|
||||
actionLabel = "View All",
|
||||
)
|
||||
}
|
||||
|
||||
val itemType = when {
|
||||
row.id.startsWith("curated:movies:genre:") -> "Movie"
|
||||
row.id.startsWith("curated:shows:genre:") -> "Series"
|
||||
row.id.startsWith("curated:") && row.id.endsWith("-shows") -> "Series"
|
||||
else -> return null
|
||||
}
|
||||
val rowLabel = row.title
|
||||
.removeSuffix(" TV Shows")
|
||||
.removeSuffix(" Shows")
|
||||
.removeSuffix(" Movies")
|
||||
.trim()
|
||||
val normalisedLabel = rowLabel.normalisedBrowseLabel()
|
||||
val category = genreCategoryTabs(itemType).firstOrNull { candidate ->
|
||||
candidate.id != ALL_MEDIA_CATEGORY_ID && (
|
||||
candidate.label.normalisedBrowseLabel() == normalisedLabel ||
|
||||
candidate.genres.any { it.normalisedBrowseLabel() == normalisedLabel }
|
||||
)
|
||||
} ?: return null
|
||||
|
||||
return HomeRowBrowseTarget(
|
||||
categoryId = category.id,
|
||||
itemType = itemType,
|
||||
actionLabel = "View All ${category.label}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.normalisedBrowseLabel(): String =
|
||||
lowercase().filter(Char::isLetterOrDigit)
|
||||
@@ -195,7 +195,6 @@ internal enum class SettingsPage(
|
||||
// its download URL with it.
|
||||
DEVICES("Devices", "TVs signed in to your account", MembyIcon.Devices),
|
||||
STORAGE("Storage", "Artwork Memby keeps on this TV", MembyIcon.Storage),
|
||||
ABOUT("About", "Version/changelog", MembyIcon.Info),
|
||||
}
|
||||
|
||||
// Black, and one lit thing at a time.
|
||||
@@ -266,7 +265,6 @@ internal data class SettingsPanelState(
|
||||
val installedVersion: String = "",
|
||||
val gatewayVersion: String = "",
|
||||
val embyVersion: String = "",
|
||||
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
|
||||
val devices: List<GatewayDevice> = emptyList(),
|
||||
val devicesLoading: Boolean = false,
|
||||
val devicesError: String? = null,
|
||||
@@ -337,8 +335,8 @@ fun SettingsSheet(
|
||||
withContext(NonCancellable) { block() }
|
||||
}
|
||||
}
|
||||
// Kept only for the version this TV is running, which About prints. Nothing here checks
|
||||
// for an update: see the note on SettingsPage.
|
||||
// Kept only for the version this TV is running, which the Settings rail prints. Nothing
|
||||
// here checks for an update: see the note on SettingsPage.
|
||||
val checker = remember { UpdateChecker(context) }
|
||||
// Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects
|
||||
// default chips before DataStore emits; a viewer could press "Automatic" in that gap
|
||||
@@ -804,6 +802,9 @@ internal fun SettingsPanelContent(
|
||||
SettingsSecondaryRail(
|
||||
selected = state.selectedPage,
|
||||
onSelected = actions.onPageSelected,
|
||||
installedVersion = state.installedVersion,
|
||||
gatewayVersion = state.gatewayVersion,
|
||||
embyVersion = state.embyVersion,
|
||||
firstFocusRequester = firstFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
@@ -1104,43 +1105,6 @@ internal fun SettingsPanelContent(
|
||||
SettingsNotice("Stored artwork cleared.", positive = true)
|
||||
}
|
||||
}
|
||||
SettingsPage.ABOUT -> SettingsGroup {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
"Version",
|
||||
color = TextPrimary,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
|
||||
color = TextSecondary,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"v${state.installedVersion}",
|
||||
color = EmbyGreen,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
SettingDivider()
|
||||
VersionRow(
|
||||
label = "Gateway server version",
|
||||
version = gatewayVersionLabel(state.gatewayVersion, state.embyVersion),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.selectedPage == SettingsPage.ABOUT) {
|
||||
VersionHistorySection(
|
||||
releases = state.releaseHistory,
|
||||
installedVersion = state.installedVersion,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
@@ -1148,7 +1112,7 @@ internal fun SettingsPanelContent(
|
||||
}
|
||||
|
||||
/**
|
||||
* What the About page prints beside "Memby gateway" — the gateway's own build, with the Emby
|
||||
* What the Settings rail prints beside "Memby Server" — the gateway's own build, with the Emby
|
||||
* it is talking to in brackets after it: `0.1.50 (4.10.0.21)`.
|
||||
*
|
||||
* The two are one row rather than two because they are one fact: this gateway, against that
|
||||
@@ -1365,6 +1329,9 @@ private const val SETTINGS_PAGE_SETTLE_MS = 130L
|
||||
private fun SettingsSecondaryRail(
|
||||
selected: SettingsPage,
|
||||
onSelected: (SettingsPage) -> Unit,
|
||||
installedVersion: String,
|
||||
gatewayVersion: String,
|
||||
embyVersion: String,
|
||||
firstFocusRequester: FocusRequester?,
|
||||
navigationFocusRequester: FocusRequester?,
|
||||
contentFocusRequester: FocusRequester,
|
||||
@@ -1532,6 +1499,24 @@ private fun SettingsSecondaryRail(
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"Memby v${installedVersion.ifBlank { "—" }}",
|
||||
color = TextSecondary,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
"Memby Server ${gatewayVersionLabel(gatewayVersion, embyVersion)}",
|
||||
color = TextQuiet,
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 14.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"↑ ↓ moves between pages",
|
||||
color = TextQuiet,
|
||||
@@ -1557,9 +1542,8 @@ private fun SettingsHeader(page: SettingsPage) {
|
||||
// already inside Memby on a television, so it named the one thing nobody could be in
|
||||
// doubt about while taking the vertical space the settings themselves want.
|
||||
//
|
||||
// No version in the corner either, for the same reason it lost the eyebrow: it was
|
||||
// on all seven pages to answer a question asked on two of them, where Updates prints
|
||||
// it as "On this TV" and About prints it beside the app's own name.
|
||||
// Version details live beneath the navigation in the rail, where they remain visible
|
||||
// without taking a page of their own.
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
@@ -1590,7 +1574,7 @@ private fun VersionRow(label: String, version: String) {
|
||||
/**
|
||||
* A run of settings on the black canvas.
|
||||
*
|
||||
* There is no card and no icon chip. Every page but About holds exactly one of these, and
|
||||
* There is no card and no icon chip. Every page holds exactly one of these, and
|
||||
* the header above it already names the page — a titled box inside a titled page said the
|
||||
* same word twice and cost two nested surfaces to do it. [label] exists only for the pages
|
||||
* that genuinely have two groups, and it is a quiet caption rather than a second heading.
|
||||
@@ -1885,113 +1869,6 @@ private fun SettingsNotice(text: String, positive: Boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The release history, as a list of collapsed releases that open in place. A remote has
|
||||
* no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
|
||||
* scroll; one focusable row per release makes Down mean "next release" until the viewer
|
||||
* asks for a particular one. The newest is open on arrival because that is the one being
|
||||
* looked for.
|
||||
*/
|
||||
@Composable
|
||||
private fun VersionHistorySection(
|
||||
releases: List<ReleaseNote>,
|
||||
installedVersion: String,
|
||||
) {
|
||||
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
|
||||
mutableStateOf(releases.firstOrNull()?.version)
|
||||
}
|
||||
SettingsGroup(label = "Changelog") {
|
||||
if (releases.isEmpty()) {
|
||||
SettingsNotice("No release history shipped with this build.", positive = false)
|
||||
return@SettingsGroup
|
||||
}
|
||||
releases.forEachIndexed { index, release ->
|
||||
if (index > 0) SettingDivider()
|
||||
ReleaseHistoryRow(
|
||||
release = release,
|
||||
installed = release.version == installedVersion,
|
||||
expanded = release.version == expandedVersion,
|
||||
onToggle = {
|
||||
expandedVersion = if (expandedVersion == release.version) null else release.version
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReleaseHistoryRow(
|
||||
release: ReleaseNote,
|
||||
installed: Boolean,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (focused) RowFocused else Color.Transparent)
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onToggle)
|
||||
.testTag("settings-release-${release.version}")
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
"v${release.version}",
|
||||
color = if (installed) EmbyGreen else TextPrimary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (installed) {
|
||||
Text(
|
||||
"INSTALLED",
|
||||
color = EmbyGreen,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.6.sp,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
release.date.takeIf(String::isNotBlank)?.let {
|
||||
Text(formatReleaseDate(it), color = TextQuiet, fontSize = 12.sp)
|
||||
}
|
||||
Text(
|
||||
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
|
||||
color = if (focused) MembyAccentInk else EmbyGreen,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.7.sp,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
|
||||
.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
release.changes.forEach { change ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text("•", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingDivider() {
|
||||
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.AccessTime
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
@@ -56,7 +57,6 @@ import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
||||
import androidx.compose.material.icons.filled.PlaylistAdd
|
||||
import androidx.compose.material.icons.filled.PlaylistRemove
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
@@ -111,7 +111,7 @@ object MaterialIconPack {
|
||||
MembyIcon.FavouriteOutline to { Icons.Default.FavoriteBorder },
|
||||
MembyIcon.Bookmark to { Icons.Default.Bookmark },
|
||||
MembyIcon.BookmarkOutline to { Icons.Default.BookmarkBorder },
|
||||
MembyIcon.PlaylistAdd to { Icons.Default.PlaylistAdd },
|
||||
MembyIcon.PlaylistAdd to { Icons.AutoMirrored.Filled.PlaylistAdd },
|
||||
MembyIcon.PlaylistRemove to { Icons.Default.PlaylistRemove },
|
||||
MembyIcon.HideWatched to { Icons.Default.VisibilityOff },
|
||||
MembyIcon.Check to { Icons.Default.Check },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -54,6 +55,7 @@ object RequiredUpdateSignal {
|
||||
* verdict that says this build is fine is exactly the evidence that the refusal is
|
||||
* spent.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun clear() {
|
||||
_required.resetReplayCache()
|
||||
}
|
||||
|
||||
@@ -146,13 +146,14 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes explicit client server protocol mismatch`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1}""",
|
||||
"""{"maintenance":false,"compatible":false,"compatibilityMessage":"App protocol 2, server protocol 1.","clientVersion":"0.9.1","clientProtocol":"2","gatewayVersion":"0.1.29","serverProtocol":1,"metadataHeroTimeRemainingColour":"white"}""",
|
||||
)
|
||||
|
||||
assertEquals(false, status.compatible)
|
||||
assertEquals("App protocol 2, server protocol 1.", status.compatibilityMessage)
|
||||
assertEquals("0.1.29", status.gatewayVersion)
|
||||
assertEquals(1, status.serverProtocol)
|
||||
assertEquals("white", status.metadataHeroTimeRemainingColour)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HomeRowBrowseTest {
|
||||
@Test
|
||||
fun `recent releases opens the complete movie catalogue`() {
|
||||
val target = homeRowBrowseTarget(row("latest-movies", "Recent New Releases", MediaRowKind.MOVIES))
|
||||
|
||||
assertEquals("all", target?.categoryId)
|
||||
assertEquals("Movie", target?.itemType)
|
||||
assertEquals("View All", target?.actionLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie and show genre rows open their matching paged catalogue`() {
|
||||
val crime = homeRowBrowseTarget(
|
||||
row("curated:movies:genre:crime", "Crime Movies", MediaRowKind.MOVIES),
|
||||
)
|
||||
val drama = homeRowBrowseTarget(
|
||||
row("curated:shows:genre:drama", "Drama Shows", MediaRowKind.SHOWS),
|
||||
)
|
||||
|
||||
assertEquals("crime", crime?.categoryId)
|
||||
assertEquals("Movie", crime?.itemType)
|
||||
assertEquals("View All Crime", crime?.actionLabel)
|
||||
assertEquals("drama", drama?.categoryId)
|
||||
assertEquals("Series", drama?.itemType)
|
||||
assertEquals("View All Drama", drama?.actionLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy show genres remain browsable`() {
|
||||
val target = homeRowBrowseTarget(
|
||||
row("curated:comedy-shows", "Comedy TV Shows", MediaRowKind.SHOWS),
|
||||
)
|
||||
|
||||
assertEquals("comedy", target?.categoryId)
|
||||
assertEquals("Series", target?.itemType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ranked and studio rows do not claim an unrelated complete catalogue`() {
|
||||
assertNull(homeRowBrowseTarget(row("recommended", "Recommended", MediaRowKind.MOVIES)))
|
||||
assertNull(
|
||||
homeRowBrowseTarget(
|
||||
row("curated:movies:studio:pixar", "More from Pixar", MediaRowKind.MOVIES),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun row(id: String, title: String, kind: MediaRowKind) = HomeBrowseRow(
|
||||
id = id,
|
||||
title = title,
|
||||
items = emptyList(),
|
||||
kind = kind,
|
||||
emptyMessage = "",
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.requestFocus
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
|
||||
class HomeRowFocusTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `row action returns focus to the card that opened it`() {
|
||||
compose.setContent {
|
||||
PreviewSurface {
|
||||
MediaRow(
|
||||
row = HomeBrowseRow(
|
||||
id = "latest-movies",
|
||||
title = "Recent releases",
|
||||
items = listOf(
|
||||
BaseItem(id = "first", name = "First film", type = "Movie"),
|
||||
BaseItem(id = "second", name = "Second film", type = "Movie"),
|
||||
),
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "No films",
|
||||
),
|
||||
availableWidth = 960.dp,
|
||||
contentEntryFocusRequester = null,
|
||||
returnFocusItemId = null,
|
||||
returnFocusRequester = remember { FocusRequester() },
|
||||
verticalFocusRequest = null,
|
||||
onVerticalFocusRequestConsumed = {},
|
||||
onMoveVertical = { _, _ -> false },
|
||||
onContentFocused = {},
|
||||
onItemFocused = { _, _ -> },
|
||||
onItemSelected = {},
|
||||
onItemLongPressed = {},
|
||||
viewAllLabel = "View All",
|
||||
onViewAll = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
val secondCard = compose.onNodeWithContentDescription("Second film", substring = true)
|
||||
secondCard.requestFocus()
|
||||
compose.waitForIdle()
|
||||
secondCard.assertIsFocused()
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithContentDescription("View All").assertIsFocused()
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
compose.waitForIdle()
|
||||
secondCard.assertIsFocused()
|
||||
}
|
||||
}
|
||||
@@ -66,5 +66,7 @@ class MetadataHeroOrderTest {
|
||||
)
|
||||
|
||||
assertEquals(60L, metadataHeroMinutesRemaining(item))
|
||||
assertEquals("60 minutes remaining", metadataHeroTimeRemainingLabel(60L))
|
||||
assertEquals("1 minute remaining", metadataHeroTimeRemainingLabel(1L))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ class ResumableMediaCardTest {
|
||||
).toResumableMediaCardModel(backdropUrl = null, primaryUrl = null)
|
||||
|
||||
assertEquals("The Show", model.title)
|
||||
assertEquals("S01E01 - Pilot", model.episodeLabel)
|
||||
assertEquals("S01E01 · Pilot", model.episodeLabel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun episodeIdentityIncludesName() {
|
||||
assertEquals("S01E01 - Pilot", episodeLabel(1, 1, "Pilot"))
|
||||
assertEquals("S01E01 · Pilot", episodeLabel(1, 1, "Pilot"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertIsNotFocused
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
@@ -32,7 +31,7 @@ class SettingsRailFocusTest {
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `up from about reaches storage`() {
|
||||
fun `up from storage reaches devices`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
@@ -43,34 +42,11 @@ class SettingsRailFocusTest {
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused()
|
||||
}
|
||||
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
|
||||
// The rail settles onto the page a beat after the focus lands on it; the press
|
||||
// worth testing is the one made after the About page is actually drawn.
|
||||
compose.waitUntil {
|
||||
compose.onAllNodesWithTag("settings-page-about").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-rail-storage").assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up from the top of the about pane leaves the pane`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("settings-rail-about").requestFocus()
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
compose.waitForIdle()
|
||||
val top = MembyReleaseHistory.first().version
|
||||
compose.onNodeWithTag("settings-release-$top").assertIsFocused()
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-release-$top").assertIsNotFocused()
|
||||
compose.onNodeWithTag("settings-rail-about").assertIsFocused()
|
||||
compose.onNodeWithTag("settings-rail-devices").assertIsFocused()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,26 +65,6 @@ class SettingsRailFocusTest {
|
||||
compose.onNodeWithTag("settings-rail-playback").assertIsFocused()
|
||||
}
|
||||
|
||||
/** The pane's own vertical navigation is untouched by the escape above it. */
|
||||
@Test
|
||||
fun `down and up move between rows inside a pane`() {
|
||||
compose.setContent { Fixture() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag("settings-rail-about").requestFocus()
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
|
||||
compose.waitForIdle()
|
||||
val releases = MembyReleaseHistory
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-release-${releases[1].version}").assertIsFocused()
|
||||
|
||||
compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-release-${releases[0].version}").assertIsFocused()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Fixture() {
|
||||
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
|
||||
@@ -118,7 +74,6 @@ class SettingsRailFocusTest {
|
||||
state = SettingsPanelState(
|
||||
selectedPage = selectedPage,
|
||||
installedVersion = "0.1.60",
|
||||
releaseHistory = MembyReleaseHistory,
|
||||
),
|
||||
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
|
||||
overlay = false,
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.ponzischeme89.memby.data.ImageCacheSize
|
||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||
import kotlinx.coroutines.delay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -155,30 +154,6 @@ class SettingsSheetScreenshotTest {
|
||||
assertEquals(togglesByPage.values.flatten().toSet(), turnedOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `about lists the release history and opens one release`() {
|
||||
compose.setContent { InteractiveSettingsFixture() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag("settings-rail-about").performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
val releases = MembyReleaseHistory
|
||||
assertTrue("the shipped changelog parsed to nothing", releases.size >= 2)
|
||||
compose.onNodeWithTag("settings-release-${releases[1].version}")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText(releases[1].changes.first()).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `about version history`() {
|
||||
compose.setContent {
|
||||
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.ABOUT)
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel wired to real state, so a press changes something a test can read. Every
|
||||
* switch starts off: the assertion worth making is that each one can be turned *on*,
|
||||
|
||||
Reference in New Issue
Block a user