0.3.10
This commit is contained in:
@@ -62,7 +62,7 @@ val projectNoticeText =
|
|||||||
rootProject.file("NOTICE").readText()
|
rootProject.file("NOTICE").readText()
|
||||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||||
|
|
||||||
val defaultVersionName = "0.3.09"
|
val defaultVersionName = "0.3.10"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -344,6 +344,10 @@ class EmbyRepository internal constructor(
|
|||||||
private val seriesEpisodesCache =
|
private val seriesEpisodesCache =
|
||||||
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||||
private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
|
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 relatedMutex = Mutex()
|
||||||
private val relatedCache =
|
private val relatedCache =
|
||||||
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
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. */
|
/** Full item metadata, requested only after focus settles on an item. */
|
||||||
suspend fun getItemDetails(itemId: String): BaseItem {
|
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)
|
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
||||||
val userId = snapshot.userId ?: error("Not connected")
|
val userId = snapshot.userId ?: error("Not connected")
|
||||||
return requireApi().getItem(
|
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
|
// 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
|
// 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
|
// 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() {
|
private suspend fun clearSeriesEpisodeCache() {
|
||||||
|
itemDetailsMutex.withLock {
|
||||||
|
itemDetailsCache.clear()
|
||||||
|
itemDetailsInFlight.values.forEach { it.cancel() }
|
||||||
|
itemDetailsInFlight.clear()
|
||||||
|
}
|
||||||
seriesEpisodesMutex.withLock {
|
seriesEpisodesMutex.withLock {
|
||||||
// Episodes carry this viewer's watched and resume state, so the same reasoning
|
// 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
|
// as below applies: another profile must not inherit them, and a request
|
||||||
@@ -3175,7 +3231,7 @@ class EmbyRepository internal constructor(
|
|||||||
// --- URL helpers ---------------------------------------------------------
|
// --- URL helpers ---------------------------------------------------------
|
||||||
|
|
||||||
/** Backdrop image URL for an item, or null if it has none. */
|
/** 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 {
|
val (id, tag) = when {
|
||||||
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
|
item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first()
|
||||||
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
|
item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() ->
|
||||||
@@ -3201,7 +3257,7 @@ class EmbyRepository internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Primary (poster) image URL, used as a card fallback. */
|
/** 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
|
val tag = item.imageTags["Primary"] ?: return null
|
||||||
return imageUrl(item.id, "Primary", tag, maxWidth)
|
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.
|
* 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.
|
* 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 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 SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||||
private const val RELATED_CACHE_SIZE = 12
|
private const val RELATED_CACHE_SIZE = 12
|
||||||
private const val RELATED_LIMIT = 12
|
private const val RELATED_LIMIT = 12
|
||||||
|
|||||||
@@ -312,12 +312,18 @@ internal fun AppRoot(
|
|||||||
// poster snapshot never reloads while update/session/onboarding checks settle.
|
// poster snapshot never reloads while update/session/onboarding checks settle.
|
||||||
// There is deliberately no decorative hold: the instant the next screen is ready,
|
// There is deliberately no decorative hold: the instant the next screen is ready,
|
||||||
// it replaces this one.
|
// 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 {
|
val opening = when {
|
||||||
// A required update is a service gate, not launcher content. Once the gateway
|
// A required update is a service gate, not launcher content. Once the gateway
|
||||||
// answers, uncover it immediately instead of making the viewer finish the
|
// answers, uncover it immediately instead of making the viewer finish the
|
||||||
// opening artwork first. Optional prompts can replace it as soon as ready.
|
// opening artwork first. Optional prompts can replace it as soon as ready.
|
||||||
update?.isMandatory == true -> false
|
update?.isMandatory == true -> false
|
||||||
!initialUpdateCheckComplete -> true
|
!initialUpdateCheckComplete && !cachedSignedInHome -> true
|
||||||
update != null -> false
|
update != null -> false
|
||||||
// Retired, verdict not here yet. The retired-build screen takes the frame
|
// 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
|
// 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.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
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.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||||
import com.ponzischeme89.memby.data.model.MediaRating
|
import com.ponzischeme89.memby.data.model.MediaRating
|
||||||
@@ -249,7 +251,8 @@ internal fun DetailBackdrop(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repository = ServiceLocator.repository
|
val repository = ServiceLocator.repository
|
||||||
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
|
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
|
// 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
|
// 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.PlaybackJourney
|
||||||
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.MembyViewer
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.MyShow
|
import com.ponzischeme89.memby.data.model.MyShow
|
||||||
@@ -648,9 +651,9 @@ internal fun HomeScreen(
|
|||||||
PlayerActivity.intent(
|
PlayerActivity.intent(
|
||||||
context = context,
|
context = context,
|
||||||
request = request,
|
request = request,
|
||||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||||
requestStartedAtMs = playbackRequestedAtMs,
|
requestStartedAtMs = playbackRequestedAtMs,
|
||||||
journeySource = entryPoint.id,
|
journeySource = entryPoint.id,
|
||||||
),
|
),
|
||||||
@@ -697,9 +700,9 @@ internal fun HomeScreen(
|
|||||||
PlayerActivity.intent(
|
PlayerActivity.intent(
|
||||||
context = context,
|
context = context,
|
||||||
playable = playable,
|
playable = playable,
|
||||||
backdropUrl = repo.backdropUrl(item, maxWidth = 1920)
|
backdropUrl = repo.backdropUrl(item, maxWidth = ARTWORK_DETAIL_BACKDROP_MAX_WIDTH)
|
||||||
?: repo.primaryUrl(item, maxWidth = 1920),
|
?: repo.primaryUrl(item, maxWidth = ARTWORK_DETAIL_PRIMARY_MAX_WIDTH),
|
||||||
posterUrl = repo.primaryUrl(item, maxWidth = 500),
|
posterUrl = repo.primaryUrl(item, maxWidth = ARTWORK_CARD_MAX_WIDTH),
|
||||||
requestStartedAtMs = playbackRequestedAtMs,
|
requestStartedAtMs = playbackRequestedAtMs,
|
||||||
journeySource = entryPoint.id,
|
journeySource = entryPoint.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
repository.playbackPositions.collect(::applyPlaybackPosition)
|
repository.playbackPositions.collect(::applyPlaybackPosition)
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.playbackStops.collect { refreshWatching() }
|
repository.playbackStops.collect { refreshWatching(authoritative = false) }
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// A D-pad produces focus changes far faster than anything should produce
|
// 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) {
|
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||||
if (!item.isSchedule) {
|
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)
|
var taggedDetails = focusedItemWithMetadata(item, details)
|
||||||
// A restored/navigation episode can be thinner than the Continue Watching
|
// 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
|
// 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".
|
// shared detail record instead of treating absent fields as "no logo".
|
||||||
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
|
if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) {
|
||||||
taggedDetails = taggedDetails.withSeriesTitleLogo(
|
taggedDetails = taggedDetails.withSeriesTitleLogo(
|
||||||
taggedDetails.seriesId?.let { loadDetailMetadata(it) },
|
taggedDetails.seriesId?.let { loadFocusedMetadata(it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (_focusedItem.value?.id == item.id) {
|
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
|
* The item record is warmed separately for the launcher's metadata panel. Related content,
|
||||||
* detail-only requests wait here, so a series page does not open with an empty
|
* trailer availability and the logo are cheap enough to prepare for a deliberate pause;
|
||||||
* Episodes pane, no progress, no next episode and no estimated finish, and every page
|
* the complete episode browser is deliberately left to the detail 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.
|
|
||||||
*
|
*
|
||||||
* **It waits longer than the metadata warm does**, and that is the whole cost control.
|
* The delay is deliberately longer than the metadata warm. A long-running show's episode
|
||||||
* An episode list is the largest request the client makes — a long-running show is a
|
* list can contain a thousand records, so it must never be fetched merely because focus
|
||||||
* thousand records — so warming one per card as somebody scans across a shelf would
|
* paused on a card.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun warmDetailPage(item: BaseItem) {
|
private suspend fun warmDetailPage(item: BaseItem) {
|
||||||
// A movie-schedule card whose film is not in the library opens a page of its own,
|
// 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.
|
// nothing waits on it and it cannot fail in a way anybody should hear about.
|
||||||
if (item.membyPlayable) repository.warmStreamConnection()
|
if (item.membyPlayable) repository.warmStreamConnection()
|
||||||
coroutineScope {
|
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
|
// 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.
|
// for every card it crosses; a press still shares the resulting single flight.
|
||||||
if (item.isMovie || item.isSeries) {
|
if (item.isMovie || item.isSeries) {
|
||||||
launch { runCatching { repository.getRelated(item) } }
|
launch { runCatching { repository.getRelated(item) } }
|
||||||
}
|
}
|
||||||
// A series is keyed on itself, an episode on the show it belongs to — which is
|
// Do not warm the complete episode browser from focus. A long-running series can
|
||||||
// exactly what its own detail page will ask for.
|
// contain hundreds of records; the detail page requests that list when the viewer
|
||||||
val seriesId = when {
|
// actually opens it, where the result is still shared and cached.
|
||||||
item.isSeries -> item.id
|
|
||||||
item.isEpisode -> item.seriesId
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (!seriesId.isNullOrBlank()) {
|
|
||||||
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
|
|
||||||
}
|
|
||||||
launch { runCatching { repository.hasTrailer(item.id) } }
|
launch { runCatching { repository.hasTrailer(item.id) } }
|
||||||
// The logo is the one part of a detail page's hero that arrived after the page
|
// 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
|
// 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()
|
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]. */
|
/** Must be called while synchronised on [metadataCache]. */
|
||||||
private fun acceptSeriesStatusRevision(revision: Long) {
|
private fun acceptSeriesStatusRevision(revision: Long) {
|
||||||
if (revision <= 0L || revision == seriesStatusRevision) return
|
if (revision <= 0L || revision == seriesStatusRevision) return
|
||||||
@@ -811,9 +822,17 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
viewModelScope.launch { persistCurrentHome() }
|
viewModelScope.launch { persistCurrentHome() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshWatching() {
|
private suspend fun refreshWatching(authoritative: Boolean = true) {
|
||||||
if (!_continueWatchingEnabled.value) return
|
if (!_continueWatchingEnabled.value) return
|
||||||
refreshMutex.withLock {
|
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) }
|
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
|
||||||
if (repository.supportsBatchHome) {
|
if (repository.supportsBatchHome) {
|
||||||
// Playback just invalidated this user's rows on the gateway anyway.
|
// 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
|
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
|
* from the press that focused it. Deliberately well past
|
||||||
* [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
|
* [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
|
* 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
|
* this one can be a thousand episode records and should only follow a viewer who
|
||||||
* has stopped. See [warmDetailPage].
|
* 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 ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||||
private const val HOME_RETRY_INITIAL_MS = 2_000L
|
private const val HOME_RETRY_INITIAL_MS = 2_000L
|
||||||
private const val HOME_RETRY_MAX_MS = 60_000L
|
private const val HOME_RETRY_MAX_MS = 60_000L
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import coil.compose.AsyncImage
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.MembySurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
|
import com.ponzischeme89.memby.ui.theme.MembyTrialTypography
|
||||||
|
|
||||||
@@ -104,7 +106,10 @@ private fun MetadataHeroArtwork(
|
|||||||
item?.parentBackdropImageTags,
|
item?.parentBackdropImageTags,
|
||||||
item?.imageTags,
|
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) {
|
val request = remember(artwork, context) {
|
||||||
artwork?.let {
|
artwork?.let {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val MAX_CACHED_CATEGORIES = 15
|
||||||
|
|
||||||
data class GenreBrowseUiState(
|
data class GenreBrowseUiState(
|
||||||
val categories: List<GenreCategory> = emptyList(),
|
val categories: List<GenreCategory> = emptyList(),
|
||||||
val selectedCategoryId: String? = null,
|
val selectedCategoryId: String? = null,
|
||||||
@@ -58,7 +60,15 @@ class GenreBrowseViewModel(
|
|||||||
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
private val _state = MutableStateFlow(GenreBrowseUiState(categories = categories))
|
||||||
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
val state: StateFlow<GenreBrowseUiState> = _state.asStateFlow()
|
||||||
private var pageJob: Job? = null
|
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.
|
* First pages being warmed for the categories either side of the selected one.
|
||||||
@@ -119,16 +129,22 @@ class GenreBrowseViewModel(
|
|||||||
|
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
val current = state.value
|
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
|
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) }
|
_state.update { it.copy(isLoadingMore = true) }
|
||||||
pageJob = viewModelScope.launch { loadPage(category, current.readOffset) }
|
pageJob = viewModelScope.launch { loadPage(category, nextOffset) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retry() {
|
fun retry() {
|
||||||
val current = state.value
|
val current = state.value
|
||||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
val categoryId = current.selectedCategoryId ?: return
|
||||||
val offset = current.readOffset
|
val category = categories.firstOrNull { it.id == categoryId } ?: return
|
||||||
|
val offset = categoryPages[categoryId]?.readOffset ?: current.readOffset
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = offset == 0,
|
isLoading = offset == 0,
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import coil.compose.AsyncImage
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.detail.formatRuntime
|
||||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
@@ -80,7 +82,10 @@ private val MetadataHeroFactsLineHeight = 18.sp
|
|||||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repo = ServiceLocator.repository
|
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) }
|
var displayedUrl by remember { mutableStateOf(imageUrl) }
|
||||||
LaunchedEffect(imageUrl) {
|
LaunchedEffect(imageUrl) {
|
||||||
if (displayedUrl == null) {
|
if (displayedUrl == null) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.ponzischeme89.memby.ui
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.Key
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
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.input.key.type
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
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.MembyOnSurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
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 kotlinx.coroutines.delay
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
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 }
|
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
|
||||||
|
|
||||||
data class HomeBrowseRow(
|
data class HomeBrowseRow(
|
||||||
@@ -67,63 +66,21 @@ data class HomeBrowseRow(
|
|||||||
val showWatchedEpisodeCount: Boolean = false,
|
val showWatchedEpisodeCount: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class HomeRowVisual(
|
private fun getHomeRowIcon(row: HomeBrowseRow): ImageVector = when {
|
||||||
val icon: ImageVector,
|
row.id == "continue" || row.id == "continue-shows" -> MembyIcon.PlayCircle.mark
|
||||||
)
|
row.kind == MediaRowKind.FAVORITES -> MembyIcon.Favourite.mark
|
||||||
|
row.id == "latest-movies" -> MembyIcon.Movie.mark
|
||||||
private val QuietText: Color get() = MembyQuietText
|
row.id == "sonarr-airing-today" -> MembyIcon.Calendar.mark
|
||||||
|
row.id == "curated:apple-tv" -> MembyIcon.LiveTv.mark
|
||||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
row.id == "curated:drama-shows" -> MembyIcon.Drama.mark
|
||||||
row.id == "continue" -> HomeRowVisual(
|
row.id == "curated:comedy-shows" -> MembyIcon.Happy.mark
|
||||||
MembyIcon.PlayCircle.mark,
|
row.id.startsWith("similar:") || row.id.startsWith("for-you:") -> MembyIcon.Sparkle.mark
|
||||||
)
|
row.id == "recommended" -> MembyIcon.Recommend.mark
|
||||||
row.id == "continue-shows" -> HomeRowVisual(
|
else -> MembyIcon.VideoLibrary.mark
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
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
|
internal val HomeRowHeaderSpacing = 10.dp
|
||||||
|
|
||||||
private val HomeRowCardsTopPadding = 12.dp
|
private val HomeRowCardsTopPadding = 12.dp
|
||||||
private val HomeRowCardsBottomPadding = 32.dp
|
private val HomeRowCardsBottomPadding = 32.dp
|
||||||
|
|
||||||
@@ -170,24 +127,26 @@ internal fun MediaRow(
|
|||||||
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
val savedRowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() }
|
||||||
val rowState = horizontalState ?: savedRowState
|
val rowState = horizontalState ?: savedRowState
|
||||||
val verticalEntryFocusRequester = remember { FocusRequester() }
|
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() }
|
?.takeIf { it.rowId == row.id && row.items.isNotEmpty() }
|
||||||
?.itemIndex
|
?.itemIndex
|
||||||
?.coerceIn(0, row.items.lastIndex)
|
?.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) {
|
LaunchedEffect(verticalFocusRequest?.requestId, requestedEntryIndex) {
|
||||||
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
|
val entryIndex = requestedEntryIndex ?: return@LaunchedEffect
|
||||||
val request = verticalFocusRequest
|
val request = verticalFocusRequest?.takeIf { it.rowId == row.id } ?: return@LaunchedEffect
|
||||||
?.takeIf { it.rowId == row.id }
|
|
||||||
?: return@LaunchedEffect
|
|
||||||
rowState.scrollToItem(entryIndex)
|
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) {
|
repeat(6) {
|
||||||
delay(16.milliseconds)
|
delay(16.milliseconds)
|
||||||
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
|
if (verticalEntryFocusRequester.requestFocusIfAttached()) {
|
||||||
@@ -197,6 +156,7 @@ internal fun MediaRow(
|
|||||||
}
|
}
|
||||||
currentOnVerticalFocusRequestConsumed(request.requestId)
|
currentOnVerticalFocusRequestConsumed(request.requestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -205,18 +165,22 @@ internal fun MediaRow(
|
|||||||
.focusGroup(),
|
.focusGroup(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val visual = homeRowVisual(row)
|
val icon = remember(row) { getHomeRowIcon(row) }
|
||||||
HomeRowHeaderIcon(visual.icon)
|
HomeRowHeaderIcon(icon)
|
||||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
||||||
Text(
|
Text(
|
||||||
row.title,
|
text = row.title,
|
||||||
color = MembyOnSurface,
|
color = MembyOnSurface,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
row.items.isEmpty() && row.loading -> {
|
row.items.isEmpty() && row.loading -> {
|
||||||
|
val isPortrait = remember(row.kind) {
|
||||||
|
row.kind == MediaRowKind.MOVIES || row.kind == MediaRowKind.SHOWS
|
||||||
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = HomeContentHorizontalInset,
|
start = HomeContentHorizontalInset,
|
||||||
@@ -226,9 +190,9 @@ internal fun MediaRow(
|
|||||||
),
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
items(5) {
|
items(count = 5) {
|
||||||
TvLoadingPlaceholder(
|
TvLoadingPlaceholder(
|
||||||
portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS),
|
portrait = isPortrait,
|
||||||
availableWidth = availableWidth,
|
availableWidth = availableWidth,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -236,8 +200,8 @@ internal fun MediaRow(
|
|||||||
}
|
}
|
||||||
row.items.isEmpty() -> {
|
row.items.isEmpty() -> {
|
||||||
Text(
|
Text(
|
||||||
row.emptyMessage,
|
text = row.emptyMessage,
|
||||||
color = QuietText,
|
color = MembyQuietText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
horizontal = HomeContentHorizontalInset,
|
horizontal = HomeContentHorizontalInset,
|
||||||
@@ -255,77 +219,84 @@ internal fun MediaRow(
|
|||||||
bottom = HomeRowCardsBottomPadding,
|
bottom = HomeRowCardsBottomPadding,
|
||||||
),
|
),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
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(),
|
modifier = Modifier.fillMaxWidth().focusGroup(),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
row.items,
|
items = row.items,
|
||||||
key = { _, item -> item.id },
|
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 ->
|
) { index, item ->
|
||||||
var cardModifier: Modifier = Modifier
|
val targetFocusRequester = when {
|
||||||
if (index == 0) {
|
index == 0 && contentEntryFocusRequester != null -> contentEntryFocusRequester
|
||||||
if (contentEntryFocusRequester != null) {
|
index == 0 && heroEntryFocusRequester != null -> heroEntryFocusRequester
|
||||||
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
|
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
|
var cardModifier = Modifier as Modifier
|
||||||
// up, that one belongs to the hero.
|
if (targetFocusRequester != null) {
|
||||||
if (heroEntryFocusRequester != null) {
|
cardModifier = cardModifier.focusRequester(targetFocusRequester)
|
||||||
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.id == returnFocusItemId) {
|
|
||||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
|
||||||
}
|
|
||||||
if (index == requestedEntryIndex) {
|
|
||||||
cardModifier = cardModifier.focusRequester(verticalEntryFocusRequester)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
cardModifier = cardModifier.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) {
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
return@onPreviewKeyEvent false
|
|
||||||
}
|
|
||||||
when (event.key) {
|
when (event.key) {
|
||||||
Key.DirectionUp -> {
|
Key.DirectionUp -> currentOnMoveVertical(index, RowFocusDirection.UP)
|
||||||
onMoveVertical(index, RowFocusDirection.UP)
|
Key.DirectionDown -> currentOnMoveVertical(index, RowFocusDirection.DOWN)
|
||||||
}
|
|
||||||
Key.DirectionDown -> onMoveVertical(index, RowFocusDirection.DOWN)
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val focused: () -> Unit = {
|
|
||||||
onContentFocused()
|
val focused = remember(item, index) {
|
||||||
// LazyRow already knows the semantic position. Passing it on
|
{
|
||||||
// avoids searching the row again on every D-pad focus move.
|
currentOnContentFocused()
|
||||||
onItemFocused(item, index)
|
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) {
|
if (row.kind == MediaRowKind.CONTINUE) {
|
||||||
ContinueWatchingCard(
|
ContinueWatchingCard(
|
||||||
item = item,
|
item = item,
|
||||||
availableWidth = availableWidth,
|
availableWidth = availableWidth,
|
||||||
portraitArtwork = format == MediaCardFormat.PORTRAIT,
|
portraitArtwork = format == MediaCardFormat.PORTRAIT,
|
||||||
onFocused = focused,
|
onFocused = focused,
|
||||||
onClick = { onItemSelected(item) },
|
onClick = onClick,
|
||||||
onLongClick = { onItemLongPressed(item) },
|
onLongClick = onLongClick,
|
||||||
modifier = cardModifier,
|
modifier = cardModifier,
|
||||||
density = density,
|
density = density,
|
||||||
)
|
)
|
||||||
} else when (format) {
|
} else when (format) {
|
||||||
MediaCardFormat.PORTRAIT -> PosterCard(
|
MediaCardFormat.PORTRAIT -> PosterCard(
|
||||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
item = item,
|
||||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
availableWidth = availableWidth,
|
||||||
density, row.showWatchedEpisodeCount,
|
showSecondaryMetadata = row.showSecondaryMetadata,
|
||||||
|
onFocused = focused,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = cardModifier,
|
||||||
|
density = density,
|
||||||
|
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
|
||||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||||
)
|
)
|
||||||
MediaCardFormat.LANDSCAPE -> {
|
MediaCardFormat.LANDSCAPE -> LandscapeCard(
|
||||||
LandscapeCard(
|
item = item,
|
||||||
item, availableWidth, row.showSecondaryMetadata, focused,
|
availableWidth = availableWidth,
|
||||||
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
|
showSecondaryMetadata = row.showSecondaryMetadata,
|
||||||
density, row.showWatchedEpisodeCount,
|
onFocused = focused,
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
modifier = cardModifier,
|
||||||
|
density = density,
|
||||||
|
showWatchedEpisodeCount = row.showWatchedEpisodeCount,
|
||||||
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
showFavouriteIndicator = row.kind != MediaRowKind.FAVORITES,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -335,7 +306,6 @@ internal fun MediaRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun FocusRequester.requestFocusIfAttached(): Boolean =
|
private fun FocusRequester.requestFocusIfAttached(): Boolean =
|
||||||
runCatching { requestFocus() }.getOrDefault(false)
|
runCatching { requestFocus() }.getOrDefault(false)
|
||||||
@@ -346,8 +316,7 @@ private fun cardFormat(
|
|||||||
kind: MediaRowKind,
|
kind: MediaRowKind,
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
artworkStyle: String = "automatic",
|
artworkStyle: String = "automatic",
|
||||||
): MediaCardFormat =
|
): MediaCardFormat = when (artworkStyle) {
|
||||||
when (artworkStyle) {
|
|
||||||
"poster" -> MediaCardFormat.PORTRAIT
|
"poster" -> MediaCardFormat.PORTRAIT
|
||||||
"backdrop" -> MediaCardFormat.LANDSCAPE
|
"backdrop" -> MediaCardFormat.LANDSCAPE
|
||||||
else -> when {
|
else -> when {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.66
|
0.1.68
|
||||||
|
|||||||
Reference in New Issue
Block a user