This commit is contained in:
ponzischeme89
2026-08-23 17:45:46 +12:00
parent d4e1cb2fbf
commit c809127996
11 changed files with 279 additions and 189 deletions
+1 -1
View File
@@ -62,7 +62,7 @@ val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.09"
val defaultVersionName = "0.3.10"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -344,6 +344,10 @@ class EmbyRepository internal constructor(
private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
private val itemDetailsMutex = Mutex()
private val itemDetailsCache =
LinkedHashMap<String, BaseItem>(DETAIL_CACHE_SIZE, 0.75f, true)
private val itemDetailsInFlight = mutableMapOf<String, Deferred<BaseItem?>>()
private val relatedMutex = Mutex()
private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
@@ -1281,6 +1285,27 @@ class EmbyRepository internal constructor(
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
require(itemId.isNotBlank()) { "Item id is required" }
val key = detailCacheKey(itemId)
val request = itemDetailsMutex.withLock {
itemDetailsCache[key]?.let { return it }
itemDetailsInFlight[key] ?: newItemDetailsRequest(key, itemId)
}
return request.await() ?: error("Could not load item $itemId")
}
/** Focus enrichment bypasses the shared flight so cancelling D-pad work cancels I/O too. */
suspend fun getItemDetailsUncached(itemId: String): BaseItem =
loadItemDetails(itemId).also { details ->
itemDetailsMutex.withLock {
itemDetailsCache[detailCacheKey(itemId)] = details
while (itemDetailsCache.size > DETAIL_CACHE_SIZE) {
itemDetailsCache.entries.iterator().run { next(); remove() }
}
}
}
private suspend fun loadItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getItem(
@@ -1290,6 +1315,32 @@ class EmbyRepository internal constructor(
)
}
private fun detailCacheKey(itemId: String): String =
"${snapshot.userId.orEmpty()}:${snapshot.activeViewerId}:$itemId"
private fun newItemDetailsRequest(key: String, itemId: String): Deferred<BaseItem?> {
lateinit var request: Deferred<BaseItem?>
request = scope.async(start = CoroutineStart.LAZY) {
try {
loadItemDetails(itemId).also { details ->
itemDetailsMutex.withLock {
itemDetailsCache[key] = details
while (itemDetailsCache.size > DETAIL_CACHE_SIZE) {
itemDetailsCache.entries.iterator().run { next(); remove() }
}
}
}
} finally {
itemDetailsMutex.withLock {
if (itemDetailsInFlight[key] === request) itemDetailsInFlight.remove(key)
}
}
}
itemDetailsInFlight[key] = request
request.start()
return request
}
// Bounded, access-ordered and synchronised rather than a plain concurrent map. These
// are process-lifetime caches on a device that is commonly left on for weeks, and an
// unbounded one holds an entry for every person, filmography and title the household
@@ -2485,6 +2536,11 @@ class EmbyRepository internal constructor(
}
private suspend fun clearSeriesEpisodeCache() {
itemDetailsMutex.withLock {
itemDetailsCache.clear()
itemDetailsInFlight.values.forEach { it.cancel() }
itemDetailsInFlight.clear()
}
seriesEpisodesMutex.withLock {
// Episodes carry this viewer's watched and resume state, so the same reasoning
// as below applies: another profile must not inherit them, and a request
@@ -3175,7 +3231,7 @@ class EmbyRepository internal constructor(
// --- URL helpers ---------------------------------------------------------
/** Backdrop image URL for an item, or null if it has none. */
fun backdropUrl(item: BaseItem, maxWidth: Int = 1920): String? {
fun backdropUrl(item: BaseItem, maxWidth: Int = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH): String? {
val (id, tag) = when {
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
@@ -3201,7 +3257,7 @@ class EmbyRepository internal constructor(
}
/** Primary (poster) image URL, used as a card fallback. */
fun primaryUrl(item: BaseItem, maxWidth: Int = 500): String? {
fun primaryUrl(item: BaseItem, maxWidth: Int = ARTWORK_CARD_MAX_WIDTH): String? {
val tag = item.imageTags["Primary"] ?: return null
return imageUrl(item.id, "Primary", tag, maxWidth)
}
@@ -3351,6 +3407,13 @@ class EmbyRepository internal constructor(
}
}
/** Canonical decoded-artwork tiers shared by launcher, detail and playback surfaces. */
const val ARTWORK_CARD_MAX_WIDTH = 500
const val ARTWORK_HERO_BACKDROP_MAX_WIDTH = 1280
const val ARTWORK_HERO_PRIMARY_MAX_WIDTH = 960
const val ARTWORK_DETAIL_BACKDROP_MAX_WIDTH = 1920
const val ARTWORK_DETAIL_PRIMARY_MAX_WIDTH = 1280
/**
* A bounded, access-ordered, thread-safe cache.
*
@@ -3567,6 +3630,7 @@ private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L
* the ones they passed on the way to it — which is the entire case the warm exists for.
*/
private const val SERIES_EPISODE_CACHE_SIZE = 10
private const val DETAIL_CACHE_SIZE = 64
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
private const val RELATED_LIMIT = 12
@@ -312,12 +312,18 @@ internal fun AppRoot(
// poster snapshot never reloads while update/session/onboarding checks settle.
// There is deliberately no decorative hold: the instant the next screen is ready,
// it replaces this one.
// A persisted Home snapshot is already usable content for an existing signed-in
// viewer. Do not hide it behind the update check: a confirmed mandatory update still
// replaces this surface below, while an optional or failed check can continue in the
// background without delaying navigation.
val cachedSignedInHome = loaded?.isSignedIn == true &&
!loaded.homeCacheJson.isNullOrBlank()
val opening = when {
// A required update is a service gate, not launcher content. Once the gateway
// answers, uncover it immediately instead of making the viewer finish the
// opening artwork first. Optional prompts can replace it as soon as ready.
update?.isMandatory == true -> false
!initialUpdateCheckComplete -> true
!initialUpdateCheckComplete && !cachedSignedInHome -> true
update != null -> false
// Retired, verdict not here yet. The retired-build screen takes the frame
// rather than the opening screen: it says what has happened and offers the one
@@ -83,6 +83,8 @@ import androidx.tv.material3.Text
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaRating
@@ -249,7 +251,8 @@ internal fun DetailBackdrop(
val context = LocalContext.current
val repository = ServiceLocator.repository
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280)
repository.backdropUrl(item, ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repository.primaryUrl(item, ARTWORK_DETAIL_PRIMARY_MAX_WIDTH)
}
// The one place in the app where a crossfade earns its keep. Artwork is loaded without
// one everywhere else because a row of posters snapping in is faster and reads fine at
@@ -75,6 +75,9 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.analytics.PlaybackJourney
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_CARD_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_DETAIL_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.data.model.MembyViewer
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
@@ -648,9 +651,9 @@ internal fun HomeScreen(
PlayerActivity.intent(
context = context,
request = request,
posterUrl = repo.primaryUrl(item, maxWidth = 500),
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
@@ -697,9 +700,9 @@ internal fun HomeScreen(
PlayerActivity.intent(
context = context,
playable = playable,
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
?: repo.primaryUrl(item, maxWidth = 1920),
posterUrl = repo.primaryUrl(item, maxWidth = 500),
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
requestStartedAtMs = playbackRequestedAtMs,
journeySource = entryPoint.id,
),
@@ -203,7 +203,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
repository.playbackPositions.collect(::applyPlaybackPosition)
}
viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() }
repository.playbackStops.collect { refreshWatching(authoritative = false) }
}
viewModelScope.launch {
// A D-pad produces focus changes far faster than anything should produce
@@ -423,7 +423,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
metadataJob = viewModelScope.launch(Dispatchers.IO) {
delay(FOCUS_METADATA_DEBOUNCE_MS)
if (!item.isSchedule) {
val details = loadDetailMetadata(item.id) ?: return@launch
// Focus enrichment belongs to this cancellable job. It must not register its
// request in the shared detail-flight map: otherwise cancelling this job only
// cancels the waiter while the view-model-owned request continues for a card
// the viewer has already crossed.
val details = loadFocusedMetadata(item.id) ?: return@launch
var taggedDetails = focusedItemWithMetadata(item, details)
// A restored/navigation episode can be thinner than the Continue Watching
// row it came from. If neither that route object nor its item response names
@@ -431,7 +435,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// shared detail record instead of treating absent fields as "no logo".
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
taggedDetails = taggedDetails.withSeriesTitleLogo(
taggedDetails.seriesId?.let { loadDetailMetadata(it) },
taggedDetails.seriesId?.let { loadFocusedMetadata(it) },
)
}
if (_focusedItem.value?.id == item.id) {
@@ -449,23 +453,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
/**
* The two requests a detail page still opened cold, warmed while the card is focused.
* Lightweight optional detail work, warmed only after a card has remained focused.
*
* The item record is warmed separately for the launcher's metadata panel. The larger
* detail-only requests wait here, so a series page does not open with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
* episode, and all of them want the same show's list.
* The item record is warmed separately for the launcher's metadata panel. Related content,
* trailer availability and the logo are cheap enough to prepare for a deliberate pause;
* the complete episode browser is deliberately left to the detail page.
*
* **It waits longer than the metadata warm does**, and that is the whole cost control.
* An episode list is the largest request the client makes — a long-running show is a
* thousand records — so warming one per card as somebody scans across a shelf would
* spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves,
* so a viewer travelling along a row never reaches it; one who has stopped on a card,
* which is what precedes a press, does. Both requests are single-flighted and cached on
* the repository, so the press that follows finds the answer rather than a second copy
* of the request.
* The delay is deliberately longer than the metadata warm. A long-running show's episode
* list can contain a thousand records, so it must never be fetched merely because focus
* paused on a card.
*/
private suspend fun warmDetailPage(item: BaseItem) {
// A movie-schedule card whose film is not in the library opens a page of its own,
@@ -483,22 +479,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
// nothing waits on it and it cannot fail in a way anybody should hear about.
if (item.membyPlayable) repository.warmStreamConnection()
coroutineScope {
// Related, episodes and trailers are detail-page work. Waiting until focus has
// Related content and trailer availability are detail-page work. Waiting until focus has
// genuinely settled prevents a held D-pad from starting long-lived requests
// for every card it crosses; a press still shares the resulting single flight.
if (item.isMovie || item.isSeries) {
launch { runCatching { repository.getRelated(item) } }
}
// A series is keyed on itself, an episode on the show it belongs to — which is
// exactly what its own detail page will ask for.
val seriesId = when {
item.isSeries -> item.id
item.isEpisode -> item.seriesId
else -> null
}
if (!seriesId.isNullOrBlank()) {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
}
// Do not warm the complete episode browser from focus. A long-running series can
// contain hundreds of records; the detail page requests that list when the viewer
// actually opens it, where the result is still shared and cached.
launch { runCatching { repository.hasTrailer(item.id) } }
// The logo is the one part of a detail page's hero that arrived after the page
// did: it is a second image, and it cannot be drawn until it has been judged
@@ -680,6 +669,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
return cached ?: request?.await()
}
/**
* Loads metadata for the currently focused card without creating a shared background
* request. D-pad focus is speculative: cancellation must reach the network call when the
* viewer moves on. A committed detail page uses [loadDetailMetadata] instead, which keeps
* its shared request alive for navigation and re-entry.
*/
private suspend fun loadFocusedMetadata(itemId: String): BaseItem? {
if (itemId.isBlank()) return null
synchronized(metadataCache) {
metadataCache[itemId]?.takeIf { detailMetadataComplete(itemId, it) }?.let { return it }
}
return try {
repository.getItemDetailsUncached(itemId).also { details ->
synchronized(metadataCache) { metadataCache[itemId] = details }
}
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (_: Throwable) {
null
}
}
/** Must be called while synchronised on [metadataCache]. */
private fun acceptSeriesStatusRevision(revision: Long) {
if (revision <= 0L || revision == seriesStatusRevision) return
@@ -811,9 +822,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
viewModelScope.launch { persistCurrentHome() }
}
private suspend fun refreshWatching() {
private suspend fun refreshWatching(authoritative: Boolean = true) {
if (!_continueWatchingEnabled.value) return
refreshMutex.withLock {
if (!authoritative) {
// PlaybackPosition has already applied the player's final position (or
// removed a completed item) to the local Continue Watching row. A full gateway
// Home response here reloads unrelated favourites, latest and recommendation
// rows immediately after every playback session.
persistCurrentHome()
return
}
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
if (repository.supportsBatchHome) {
// Playback just invalidated this user's rows on the gateway anyway.
@@ -888,14 +907,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val FOCUS_METADATA_DEBOUNCE_MS = 200L
/**
* How long focus must rest on a card before its detail page is warmed, measured
* How long focus must rest on a card before lightweight detail work is warmed, measured
* from the press that focused it. Deliberately well past
* [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
* what the panel beside the row says, so it should follow the D-pad closely, while
* this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage].
*/
private const val DETAIL_PREFETCH_DELAY_MS = 500L
private const val DETAIL_PREFETCH_DELAY_MS = 1_200L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private const val HOME_RETRY_INITIAL_MS = 2_000L
private const val HOME_RETRY_MAX_MS = 60_000L
@@ -20,6 +20,8 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_HERO_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_HERO_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
@@ -104,7 +106,10 @@ private fun MetadataHeroArtwork(
item?.parentBackdropImageTags,
item?.imageTags,
) {
item?.let { repository.backdropUrl(it, maxWidth = 1280) ?: repository.primaryUrl(it, maxWidth = 960) }
item?.let {
repository.backdropUrl(it, maxWidth = ARTWORK_HERO_BACKDROP_MAX_WIDTH)
?: repository.primaryUrl(it, maxWidth = ARTWORK_HERO_PRIMARY_MAX_WIDTH)
}
}
val request = remember(artwork, context) {
artwork?.let {
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
private const val MAX_CACHED_CATEGORIES = 15
data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null,
@@ -58,7 +60,15 @@ class GenreBrowseViewModel(
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
private var pageJob: Job? = null
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
/** LRU cache bounding the memory footprint for large library category pages. */
private val categoryPages = object : LinkedHashMap<String, CachedCategoryPage>(
16, 0.75f, true
) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CachedCategoryPage>?): Boolean {
return size > MAX_CACHED_CATEGORIES
}
}
/**
* First pages being warmed for the categories either side of the selected one.
@@ -119,16 +129,22 @@ class GenreBrowseViewModel(
fun loadMore() {
val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
// Direct cache query prevents offset races caused by rapid concurrent state updates.
val nextOffset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(category, current.readOffset) }
pageJob = viewModelScope.launch { loadPage(category, nextOffset) }
}
fun retry() {
val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
val offset = current.readOffset
val categoryId = current.selectedCategoryId ?: return
val category = categories.firstOrNull { it.id == categoryId } ?: return
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
_state.update {
it.copy(
isLoading = offset == 0,
@@ -45,6 +45,8 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.ARTWORK_HERO_BACKDROP_MAX_WIDTH
import com.ponzischeme89.memby.data.ARTWORK_HERO_PRIMARY_MAX_WIDTH
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -80,7 +82,10 @@ private val MetadataHeroFactsLineHeight = 18.sp
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
val context = LocalContext.current
val repo = ServiceLocator.repository
val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) }
val imageUrl = item?.let {
repo.backdropUrl(it, ARTWORK_HERO_BACKDROP_MAX_WIDTH)
?: repo.primaryUrl(it, ARTWORK_HERO_PRIMARY_MAX_WIDTH)
}
var displayedUrl by remember { mutableStateOf(imageUrl) }
LaunchedEffect(imageUrl) {
if (displayedUrl == null) {
@@ -1,7 +1,5 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.mark
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -35,10 +33,10 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
@@ -47,13 +45,14 @@ 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.components.media.ContinueWatchingCard
import com.ponzischeme89.memby.ui.theme.MembyIcon
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard
import com.ponzischeme89.memby.ui.theme.mark
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
data class HomeBrowseRow(
@@ -67,63 +66,21 @@ data class HomeBrowseRow(
val showWatchedEpisodeCount: Boolean = false,
)
private data class HomeRowVisual(
val icon: ImageVector,
)
private val QuietText: Color get() = MembyQuietText
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
MembyIcon.PlayCircle.mark,
)
row.id == "continue-shows" -> HomeRowVisual(
MembyIcon.PlayCircle.mark,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
MembyIcon.Favourite.mark,
)
row.id == "latest-movies" -> HomeRowVisual(
MembyIcon.Movie.mark,
)
row.id == "sonarr-airing-today" -> HomeRowVisual(
MembyIcon.Calendar.mark,
)
row.id == "curated:apple-tv" -> HomeRowVisual(
MembyIcon.LiveTv.mark,
)
row.id == "curated:drama-shows" -> HomeRowVisual(
MembyIcon.Drama.mark,
)
row.id == "curated:comedy-shows" -> HomeRowVisual(
MembyIcon.Happy.mark,
)
row.id.startsWith("similar:") -> HomeRowVisual(
MembyIcon.Sparkle.mark,
)
row.id == "recommended" -> HomeRowVisual(
MembyIcon.Recommend.mark,
)
row.id.startsWith("for-you:") -> HomeRowVisual(
MembyIcon.Sparkle.mark,
)
else -> HomeRowVisual(
MembyIcon.VideoLibrary.mark,
)
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
row.id == "continue" || row.id == "continue-shows" -> MembyIcon.PlayCircle.mark
row.kind == MediaRowKind.FAVORITES -> MembyIcon.Favourite.mark
row.id == "latest-movies" -> MembyIcon.Movie.mark
row.id == "sonarr-airing-today" -> MembyIcon.Calendar.mark
row.id == "curated:apple-tv" -> MembyIcon.LiveTv.mark
row.id == "curated:drama-shows" -> MembyIcon.Drama.mark
row.id == "curated:comedy-shows" -> MembyIcon.Happy.mark
row.id.startsWith("similar:") || row.id.startsWith("for-you:") -> MembyIcon.Sparkle.mark
row.id == "recommended" -> MembyIcon.Recommend.mark
else -> MembyIcon.VideoLibrary.mark
}
/**
* The 28dp icon chip every home row header leads with, and the gap after it.
*
* Extracted because a header that skipped the chip started its title 38dp further left than
* the rows above and below it. The launcher's row titles are read as one column, and one
* that steps sideways breaks the column.
*/
internal val HomeRowHeaderIconGap = 10.dp
/** Vertical gap between a row's header and its cards. One value for every row. */
internal val HomeRowHeaderSpacing = 10.dp
private val HomeRowCardsTopPadding = 12.dp
private val HomeRowCardsBottomPadding = 32.dp
@@ -170,24 +127,26 @@ internal fun MediaRow(
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
val rowState = horizontalState ?: savedRowState
val verticalEntryFocusRequester = remember { FocusRequester() }
val requestedEntryIndex = verticalFocusRequest
val requestedEntryIndex = remember(verticalFocusRequest, row.id, row.items.size) {
verticalFocusRequest
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
?.itemIndex
?.coerceIn(0, row.items.lastIndex)
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(
onVerticalFocusRequestConsumed,
)
}
val currentOnVerticalFocusRequestConsumed by rememberUpdatedState(onVerticalFocusRequestConsumed)
val currentOnContentFocused by rememberUpdatedState(onContentFocused)
val currentOnItemFocused by rememberUpdatedState(onItemFocused)
val currentOnItemSelected by rememberUpdatedState(onItemSelected)
val currentOnItemLongPressed by rememberUpdatedState(onItemLongPressed)
val currentOnMoveVertical by rememberUpdatedState(onMoveVertical)
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
val request = verticalFocusRequest
?.takeIf { it.rowId == row.id }
?: return@LaunchedEffect
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
rowState.scrollToItem(entryIndex)
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus
// node to attach before transferring focus; retrying covers slower TV frames. Six
// attempts rather than three because giving up here is a press that visibly does
// nothing, and the shelf this arrives at is commonly one being composed for the
// first time — the frame budget on a Chromecast is not the one a warm row gets.
repeat(6) {
delay(16.milliseconds)
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
@@ -197,6 +156,7 @@ internal fun MediaRow(
}
currentOnVerticalFocusRequestConsumed(request.requestId)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
Row(
modifier = Modifier
@@ -205,18 +165,22 @@ internal fun MediaRow(
.focusGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
val visual = homeRowVisual(row)
HomeRowHeaderIcon(visual.icon)
val icon = remember(row) { getHomeRowIcon(row) }
HomeRowHeaderIcon(icon)
Spacer(Modifier.width(HomeRowHeaderIconGap))
Text(
row.title,
text = row.title,
color = MembyOnSurface,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
}
when {
row.items.isEmpty() && row.loading -> {
val isPortrait = remember(row.kind) {
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
}
LazyRow(
contentPadding = PaddingValues(
start = HomeContentHorizontalInset,
@@ -226,9 +190,9 @@ internal fun MediaRow(
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(5) {
items(count = 5) {
TvLoadingPlaceholder(
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
portrait = isPortrait,
availableWidth = availableWidth,
)
}
@@ -236,8 +200,8 @@ internal fun MediaRow(
}
row.items.isEmpty() -> {
Text(
row.emptyMessage,
color = QuietText,
text = row.emptyMessage,
color = MembyQuietText,
fontSize = 14.sp,
modifier = Modifier.padding(
horizontal = HomeContentHorizontalInset,
@@ -255,77 +219,84 @@ internal fun MediaRow(
bottom = HomeRowCardsBottomPadding,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
// focusRestorer pins a lazy item. Replacing a For You result set
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
modifier = Modifier.fillMaxWidth().focusGroup(),
) {
itemsIndexed(
row.items,
items = row.items,
key = { _, item -> item.id },
contentType = { _, item -> if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" },
contentType = { _, item ->
if (cardFormat(row.kind, item, artworkStyle) == MediaCardFormat.PORTRAIT) "portrait" else "landscape"
},
) { index, item ->
var cardModifier: Modifier = Modifier
if (index == 0) {
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
val targetFocusRequester = when {
index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
item.id == returnFocusItemId -> returnFocusRequester
index == requestedEntryIndex -> verticalEntryFocusRequester
else -> null
}
// Where Down out of the home hero lands. It is a second
// requester rather than the entry one because while the hero is
// up, that one belongs to the hero.
if (heroEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
}
}
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
}
if (index == requestedEntryIndex) {
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
var cardModifier = Modifier as Modifier
if (targetFocusRequester != null) {
cardModifier = cardModifier.focusRequester(targetFocusRequester)
}
cardModifier = cardModifier.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionUp -> {
onMoveVertical(index, RowFocusDirection.UP)
}
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
Key.DirectionUp -> currentOnMoveVertical(index, RowFocusDirection.UP)
Key.DirectionDown -> currentOnMoveVertical(index, RowFocusDirection.DOWN)
else -> false
}
}
val focused: () -> Unit = {
onContentFocused()
// LazyRow already knows the semantic position. Passing it on
// avoids searching the row again on every D-pad focus move.
onItemFocused(item, index)
val focused = remember(item, index) {
{
currentOnContentFocused()
currentOnItemFocused(item, index)
}
val format = cardFormat(row.kind, item, artworkStyle)
}
val onClick = remember(item) { { currentOnItemSelected(item) } }
val onLongClick = remember(item) { { currentOnItemLongPressed(item) } }
val format = remember(row.kind, item, artworkStyle) {
cardFormat(row.kind, item, artworkStyle)
}
if (row.kind == MediaRowKind.CONTINUE) {
ContinueWatchingCard(
item = item,
availableWidth = availableWidth,
portraitArtwork = format == MediaCardFormat.PORTRAIT,
onFocused = focused,
onClick = { onItemSelected(item) },
onLongClick = { onItemLongPressed(item) },
onClick = onClick,
onLongClick = onLongClick,
modifier = cardModifier,
density = density,
)
} else when (format) {
MediaCardFormat.PORTRAIT -> PosterCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
item = item,
availableWidth = availableWidth,
showSecondaryMetadata = row.showSecondaryMetadata,
onFocused = focused,
onClick = onClick,
onLongClick = onLongClick,
modifier = cardModifier,
density = density,
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
)
MediaCardFormat.LANDSCAPE -> {
LandscapeCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
MediaCardFormat.LANDSCAPE -> LandscapeCard(
item = item,
availableWidth = availableWidth,
showSecondaryMetadata = row.showSecondaryMetadata,
onFocused = focused,
onClick = onClick,
onLongClick = onLongClick,
modifier = cardModifier,
density = density,
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
)
}
@@ -335,7 +306,6 @@ internal fun MediaRow(
}
}
}
}
private fun FocusRequester.requestFocusIfAttached(): Boolean =
runCatching { requestFocus() }.getOrDefault(false)
@@ -346,8 +316,7 @@ private fun cardFormat(
kind: MediaRowKind,
item: BaseItem,
artworkStyle: String = "automatic",
): MediaCardFormat =
when (artworkStyle) {
): MediaCardFormat = when (artworkStyle) {
"poster" -> MediaCardFormat.PORTRAIT
"backdrop" -> MediaCardFormat.LANDSCAPE
else -> when {
+1 -1
View File
@@ -1 +1 @@
0.1.66
0.1.68