651 lines
28 KiB
Kotlin
651 lines
28 KiB
Kotlin
package com.ponzischeme89.memby.ui
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.ViewModelProvider
|
|
import androidx.lifecycle.viewModelScope
|
|
import com.ponzischeme89.memby.data.EmbyRepository
|
|
import com.ponzischeme89.memby.data.HomeCache
|
|
import com.ponzischeme89.memby.data.HomeSnapshot
|
|
import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
|
import com.ponzischeme89.memby.data.friendlyEmbyError
|
|
import com.ponzischeme89.memby.data.isMaintenanceError
|
|
import com.ponzischeme89.memby.data.model.BaseItem
|
|
import com.ponzischeme89.memby.data.model.HomeRow
|
|
import com.ponzischeme89.memby.data.model.UserItemData
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.coroutineScope
|
|
import kotlinx.coroutines.flow.MutableStateFlow
|
|
import kotlinx.coroutines.flow.SharingStarted
|
|
import kotlinx.coroutines.flow.StateFlow
|
|
import kotlinx.coroutines.flow.asStateFlow
|
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
import kotlinx.coroutines.flow.map
|
|
import kotlinx.coroutines.flow.stateIn
|
|
import kotlinx.coroutines.flow.update
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.sync.Mutex
|
|
import kotlinx.coroutines.sync.withLock
|
|
|
|
enum class HomeSection { CONTINUE, FAVORITES, LATEST }
|
|
|
|
data class HomeUiState(
|
|
/**
|
|
* Everything in progress, including the episode that follows one just finished:
|
|
* Continue Watching and Next Up are one row. The merge happens on the gateway, or in
|
|
* `EmbyRepository.getContinueWatching` on the direct path.
|
|
*/
|
|
val continueWatching: List<BaseItem> = emptyList(),
|
|
val favorites: List<BaseItem> = emptyList(),
|
|
val latestMovies: List<BaseItem> = emptyList(),
|
|
/**
|
|
* Rows as composed by the gateway, including recommendation strips. Empty on the
|
|
* direct-to-Emby path, where the client composes rows itself.
|
|
*/
|
|
val rows: List<HomeRow> = emptyList(),
|
|
val loading: Set<HomeSection> = HomeSection.entries.toSet(),
|
|
val hasRefreshError: Boolean = false,
|
|
/**
|
|
* A message worth showing above the rows. Null means "use the generic
|
|
* slow-connection wording".
|
|
*/
|
|
val statusMessage: String? = null,
|
|
/**
|
|
* Set when the gateway reports a deliberate outage. Distinct from [statusMessage]
|
|
* because this replaces the whole content area rather than adding a banner — the
|
|
* rows behind it would be stale and unusable anyway.
|
|
*/
|
|
val maintenanceMessage: String? = null,
|
|
) {
|
|
/**
|
|
* This state with everything that is *not* a row blanked out. Paired with
|
|
* `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only
|
|
* emits when something changed about what is on the rows — see the note there.
|
|
*
|
|
* Read rows and [loading] from this; the blanked fields are meaningless in it.
|
|
*/
|
|
fun contentSlice(): HomeUiState = copy(
|
|
hasRefreshError = false,
|
|
statusMessage = null,
|
|
maintenanceMessage = null,
|
|
)
|
|
|
|
fun statusSlice(): HomeStatus = HomeStatus(
|
|
hasRefreshError = hasRefreshError,
|
|
statusMessage = statusMessage,
|
|
maintenanceMessage = maintenanceMessage,
|
|
)
|
|
|
|
fun toCache() = HomeCache(
|
|
continueWatching = continueWatching,
|
|
favorites = favorites,
|
|
latestMovies = latestMovies,
|
|
rows = rows,
|
|
)
|
|
|
|
companion object {
|
|
fun from(cache: HomeCache?) = HomeUiState(
|
|
// A cache written before the two rows merged still carries its Next Up items
|
|
// separately; folding them in keeps the cold-start row complete until the
|
|
// first refresh replaces it with a properly interleaved one.
|
|
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
|
|
.distinctBy(BaseItem::id),
|
|
favorites = cache?.favorites.orEmpty(),
|
|
latestMovies = cache?.latestMovies.orEmpty(),
|
|
rows = cache?.rows.orEmpty(),
|
|
loading = buildSet {
|
|
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
|
|
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
|
|
if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The connection-health slice of [HomeUiState]: what the banner and the maintenance
|
|
* screen need, and nothing that would drag the rows into a recomposition with it.
|
|
*/
|
|
data class HomeStatus(
|
|
val hasRefreshError: Boolean = false,
|
|
val statusMessage: String? = null,
|
|
val maintenanceMessage: String? = null,
|
|
)
|
|
|
|
data class ForYouUiState(
|
|
val rows: List<HomeRow> = emptyList(),
|
|
val availableMinutes: Int = 0,
|
|
val loading: Boolean = false,
|
|
val error: String? = null,
|
|
)
|
|
|
|
class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|
private val refreshMutex = Mutex()
|
|
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
|
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
|
|
|
// HomeScreen is a very large composable, so reading the whole of [state] there meant
|
|
// every emission invalidated the launcher: the slow-
|
|
// connection banner and each of the four section loads all recomposed the rows, the
|
|
// rail and every overlay, and rebuilt the row list with them. These three narrow
|
|
// projections let it subscribe only to what each part actually renders.
|
|
|
|
/** Rows and their loading flags. Does not emit for banner changes. */
|
|
val content: StateFlow<HomeUiState> = _state
|
|
.map(HomeUiState::contentSlice)
|
|
.distinctUntilChanged()
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())
|
|
|
|
/** Connection health. Does not emit when rows change. */
|
|
val status: StateFlow<HomeStatus> = _state
|
|
.map(HomeUiState::statusSlice)
|
|
.distinctUntilChanged()
|
|
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())
|
|
|
|
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
|
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
|
// Genre pages own paged copies that do not live in HomeUiState. This small mutation
|
|
// stream lets those copies reflect a heart press immediately, including a rollback
|
|
// when the server rejects it, without refreshing or losing the active category.
|
|
private val _favoriteChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
|
val favoriteChanges: StateFlow<Map<String, Boolean>> = _favoriteChanges.asStateFlow()
|
|
private val _playedChanges = MutableStateFlow<Map<String, Boolean>>(emptyMap())
|
|
val playedChanges: StateFlow<Map<String, Boolean>> = _playedChanges.asStateFlow()
|
|
private val _forYou = MutableStateFlow(ForYouUiState())
|
|
val forYou: StateFlow<ForYouUiState> = _forYou.asStateFlow()
|
|
private var metadataJob: Job? = null
|
|
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
|
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): Boolean = size > 32
|
|
}
|
|
|
|
/** Row engagement, buffered here and uploaded in batches. */
|
|
private val analytics = RowAnalytics()
|
|
|
|
init {
|
|
refreshAll()
|
|
viewModelScope.launch {
|
|
repository.playbackStops.collect { refreshWatching() }
|
|
}
|
|
viewModelScope.launch {
|
|
// A D-pad produces focus changes far faster than anything should produce
|
|
// HTTP requests, so engagement is uploaded on a slow drumbeat instead.
|
|
while (true) {
|
|
delay(ANALYTICS_FLUSH_INTERVAL_MS)
|
|
flushAnalytics()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
|
|
analytics.rowImpression(rowId, rowKind, visibleItemIds)
|
|
|
|
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
|
analytics.rowFocused(rowId, rowKind, itemId)
|
|
|
|
fun trackRowSelected(rowId: String, rowKind: String, itemId: String) =
|
|
analytics.rowSelected(rowId, rowKind, itemId)
|
|
|
|
/**
|
|
* Closes the open dwell measurement and uploads. Called on a timer and when the home
|
|
* screen stops, so time spent sitting on one row is not lost.
|
|
*/
|
|
fun flushAnalytics() {
|
|
analytics.endFocus()
|
|
repository.reportRowEvents(analytics.drain())
|
|
}
|
|
|
|
fun loadForYou(availableMinutes: Int = _forYou.value.availableMinutes) {
|
|
val minutes = availableMinutes.coerceIn(0, 360)
|
|
_focusedItem.value = null
|
|
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
runCatching { repository.getForYou(minutes) }
|
|
.onSuccess { rows ->
|
|
_forYou.value = ForYouUiState(
|
|
rows = rows,
|
|
availableMinutes = minutes,
|
|
loading = false,
|
|
)
|
|
rows.firstNotNullOfOrNull { it.items.firstOrNull() }?.let(::focusItem)
|
|
}
|
|
.onFailure {
|
|
_forYou.update {
|
|
it.copy(
|
|
loading = false,
|
|
error = "For You is temporarily unavailable",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun refreshAll() {
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
refreshMutex.withLock {
|
|
// A refresh can make a newly imported episode the next playable item for
|
|
// an existing series ID. Never launch the pre-refresh negotiated session.
|
|
repository.invalidatePlaybackPrefetch()
|
|
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
|
|
if (repository.supportsBatchHome) {
|
|
loadBatchHome()
|
|
} else {
|
|
coroutineScope {
|
|
launch { loadContinueWatching() }
|
|
launch { loadFavorites() }
|
|
launch { loadLatest() }
|
|
}
|
|
}
|
|
persistCurrentHome()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The gateway returns every row in one response, so the four-way fan-out collapses
|
|
* into a single request and the rows can no longer arrive out of step with each other.
|
|
*/
|
|
private suspend fun loadBatchHome() {
|
|
runCatching { repository.getHome() }
|
|
.onSuccess { home ->
|
|
val taggedHome = home.withAiringTodayTags()
|
|
_state.update { current ->
|
|
current.copy(
|
|
continueWatching = taggedHome.continueWatching,
|
|
favorites = taggedHome.favorites,
|
|
latestMovies = taggedHome.latestMovies,
|
|
// Recommendation rows are built in the background by the gateway,
|
|
// so an early response can arrive without them. Keeping the rows
|
|
// we already had stops the strip flickering out and back in.
|
|
rows = taggedHome.rows.ifEmpty { current.rows },
|
|
loading = emptySet(),
|
|
hasRefreshError = taggedHome.partial,
|
|
statusMessage = null,
|
|
// A successful response is the only thing that clears the
|
|
// maintenance screen, so a retry that fails keeps it up.
|
|
maintenanceMessage = null,
|
|
)
|
|
}
|
|
if (_focusedItem.value == null) {
|
|
initialFocusedItem(_state.value)?.let(::focusItem)
|
|
}
|
|
// Home may have been cached just before the background recommendation
|
|
// build completed. Pull the dedicated endpoint after the fast home draw
|
|
// so personalized Shows shelves appear on this visit, not a minute later.
|
|
refreshRecommendationRows()
|
|
}
|
|
.onFailure { error ->
|
|
val maintenance = isMaintenanceError(error)
|
|
_state.update {
|
|
it.copy(
|
|
loading = emptySet(),
|
|
hasRefreshError = true,
|
|
statusMessage = null,
|
|
maintenanceMessage = if (maintenance) friendlyEmbyError(error) else null,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun refreshRecommendationRows() {
|
|
val fresh = runCatching { repository.getRecommendations() }.getOrNull() ?: return
|
|
_state.update { state ->
|
|
val airingTodayKeys = state.rows.airingTodayShowKeys()
|
|
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
|
|
val fixedRows = state.rows.filterNot { row ->
|
|
row.id == "recommended" ||
|
|
row.id.startsWith("similar:") ||
|
|
row.id.startsWith("curated:")
|
|
}
|
|
state.copy(rows = fixedRows + taggedFresh)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates local metadata immediately, then enriches it only after focus settles.
|
|
* Cancelling the previous job prevents stale responses from winning rapid D-pad navigation.
|
|
*/
|
|
fun focusItem(item: BaseItem) {
|
|
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
|
val focused = focusedItemWithMetadata(item, cached)
|
|
_focusedItem.value = focused
|
|
metadataJob?.cancel()
|
|
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
|
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
|
coroutineScope {
|
|
// Resolution is tiny compared with video buffering and makes the later
|
|
// Play click a memory lookup. The repository single-flights requests,
|
|
// so focus and click can never duplicate the gateway call.
|
|
if (item.membyPlayable) {
|
|
// The row owns live UserData. Prefetching the metadata-cache copy used
|
|
// to turn a 5% resume point into zero after details had been visited.
|
|
launch { runCatching { repository.prefetchPlayable(focused) } }
|
|
}
|
|
// Warm the explanation and franchise siblings while the card is already
|
|
// focused, so opening Details does not add a reason line a frame later.
|
|
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
|
|
launch { runCatching { repository.getRelated(focused) } }
|
|
}
|
|
if (cached == null && !item.isSchedule) {
|
|
launch {
|
|
val details = runCatching {
|
|
repository.getItemDetails(item.id)
|
|
}.getOrNull() ?: return@launch
|
|
val taggedDetails = focusedItemWithMetadata(item, details)
|
|
synchronized(metadataCache) { metadataCache[item.id] = taggedDetails }
|
|
if (_focusedItem.value?.id == item.id) {
|
|
_focusedItem.value = taggedDetails
|
|
}
|
|
}
|
|
}
|
|
launch { warmDetailPage(item) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The two requests a detail page still opened cold, warmed while the card is focused.
|
|
*
|
|
* Everything else the page needs is already in hand by the time it opens — the item
|
|
* record, its "why you might enjoy it" and its playable URL are all warmed above — but
|
|
* the episode list and the trailer were not, so a series page opened 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.
|
|
*
|
|
* **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.
|
|
*/
|
|
private suspend fun warmDetailPage(item: BaseItem) {
|
|
if (item.isSchedule) return
|
|
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
|
|
coroutineScope {
|
|
// 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) } }
|
|
}
|
|
launch { runCatching { repository.getLocalTrailer(item.id) } }
|
|
}
|
|
}
|
|
|
|
fun setFavorite(item: BaseItem, favorite: Boolean) {
|
|
updateFavorite(item, favorite)
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
runCatching { repository.setFavorite(item.id, favorite) }
|
|
.onSuccess { confirmed ->
|
|
updateFavorite(item, confirmed)
|
|
}
|
|
.onFailure {
|
|
updateFavorite(item, !favorite)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateFavorite(item: BaseItem, favorite: Boolean) {
|
|
_favoriteChanges.update { it + (item.id to favorite) }
|
|
updateUserData(item.id) { it.copy(isFavorite = favorite) }
|
|
_state.update { state ->
|
|
val updatedItem = item.copy(
|
|
userData = (item.userData ?: UserItemData()).copy(isFavorite = favorite),
|
|
)
|
|
state.copy(
|
|
favorites = if (favorite) {
|
|
(state.favorites + updatedItem).distinctBy(BaseItem::id)
|
|
} else {
|
|
state.favorites.filterNot { it.id == item.id }
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
fun setPlayed(item: BaseItem, played: Boolean) {
|
|
val previousPlayed = item.userData?.played == true
|
|
val previousPosition = item.userData?.playbackPositionTicks ?: 0L
|
|
_playedChanges.update { it + (item.id to played) }
|
|
updateUserData(item.id) {
|
|
it.copy(
|
|
played = played,
|
|
playbackPositionTicks = if (played) 0L else it.playbackPositionTicks,
|
|
)
|
|
}
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
runCatching { repository.setPlayed(item.id, played) }
|
|
.onSuccess { confirmed ->
|
|
_playedChanges.update { it + (item.id to confirmed) }
|
|
updateUserData(item.id) {
|
|
it.copy(
|
|
played = confirmed,
|
|
playbackPositionTicks = if (confirmed) 0L else it.playbackPositionTicks,
|
|
)
|
|
}
|
|
}
|
|
.onFailure {
|
|
_playedChanges.update { it + (item.id to previousPlayed) }
|
|
updateUserData(item.id) {
|
|
it.copy(played = previousPlayed, playbackPositionTicks = previousPosition)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fun removeFromContinueWatching(item: BaseItem) {
|
|
val previous = _state.value
|
|
_state.update { state ->
|
|
state.copy(
|
|
continueWatching = state.continueWatching.filterNot { it.id == item.id },
|
|
rows = state.rows.map { row ->
|
|
if (row.kind == "continue" || row.kind == "nextup") {
|
|
row.copy(items = row.items.filterNot { it.id == item.id })
|
|
} else {
|
|
row
|
|
}
|
|
},
|
|
)
|
|
}
|
|
_focusedItem.update { focused -> focused?.takeUnless { it.id == item.id } }
|
|
viewModelScope.launch(Dispatchers.IO) {
|
|
runCatching { repository.removeFromContinueWatching(item.id) }
|
|
.onSuccess { persistCurrentHome() }
|
|
.onFailure {
|
|
_state.value = previous
|
|
_focusedItem.value = item
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) {
|
|
fun BaseItem.updated(): BaseItem =
|
|
if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this
|
|
|
|
_state.update {
|
|
it.copy(
|
|
continueWatching = it.continueWatching.map(BaseItem::updated),
|
|
favorites = it.favorites.map(BaseItem::updated),
|
|
latestMovies = it.latestMovies.map(BaseItem::updated),
|
|
// Server rows hold their own copies of the same items, so an optimistic
|
|
// favourite/watched toggle has to reach into them too or the heart on a
|
|
// recommendation card would not light up.
|
|
rows = it.rows.map { row -> row.copy(items = row.items.map(BaseItem::updated)) },
|
|
)
|
|
}
|
|
_focusedItem.update { it?.updated() }
|
|
synchronized(metadataCache) {
|
|
metadataCache[itemId]?.let { metadataCache[itemId] = it.updated() }
|
|
}
|
|
}
|
|
|
|
private suspend fun refreshWatching() {
|
|
refreshMutex.withLock {
|
|
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
|
|
if (repository.supportsBatchHome) {
|
|
// Playback just invalidated this user's rows on the gateway anyway.
|
|
loadBatchHome()
|
|
} else {
|
|
loadContinueWatching(clearLoading = false)
|
|
}
|
|
_state.update { it.copy(loading = it.loading - HomeSection.CONTINUE) }
|
|
persistCurrentHome()
|
|
}
|
|
}
|
|
|
|
private suspend fun loadContinueWatching(clearLoading: Boolean = true) =
|
|
load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items ->
|
|
state.copy(continueWatching = items)
|
|
}
|
|
|
|
private suspend fun loadFavorites() =
|
|
load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
|
|
state.copy(favorites = items)
|
|
}
|
|
|
|
private suspend fun loadLatest() =
|
|
load(HomeSection.LATEST, true, { repository.getLatestMovies() }) { state, items ->
|
|
state.copy(latestMovies = items)
|
|
}
|
|
|
|
private suspend fun load(
|
|
section: HomeSection,
|
|
clearLoading: Boolean,
|
|
request: suspend () -> List<BaseItem>,
|
|
updateItems: (HomeUiState, List<BaseItem>) -> HomeUiState,
|
|
) {
|
|
runCatching { request() }
|
|
.onSuccess { items ->
|
|
_state.update { current ->
|
|
updateItems(current, items).let {
|
|
if (clearLoading) it.copy(loading = it.loading - section) else it
|
|
}
|
|
}
|
|
if (_focusedItem.value == null) {
|
|
items.firstOrNull()?.let(::focusItem)
|
|
}
|
|
}
|
|
.onFailure {
|
|
_state.update { current ->
|
|
current.copy(
|
|
loading = if (clearLoading) current.loading - section else current.loading,
|
|
hasRefreshError = true,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private suspend fun persistCurrentHome() {
|
|
runCatching { repository.cacheHome(_state.value.toCache()) }
|
|
}
|
|
|
|
override fun onCleared() {
|
|
flushAnalytics()
|
|
super.onCleared()
|
|
}
|
|
|
|
companion object {
|
|
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
|
|
|
/**
|
|
* How long focus must rest on a card before its detail page 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 = 450L
|
|
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
|
|
|
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
|
state.continueWatching.firstOrNull()
|
|
?: state.latestMovies.firstOrNull()
|
|
?: state.favorites.firstOrNull()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds rich detail metadata without replacing the row's live per-user state.
|
|
*
|
|
* Continue Watching is the important case: its card knows the current playhead, while a
|
|
* cached/full metadata record may carry no UserData at all. Losing that state changes the
|
|
* button from Resume to Play and sends a zero resume position into playback.
|
|
*/
|
|
internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseItem =
|
|
(metadata ?: item).copy(
|
|
userData = item.userData ?: metadata?.userData,
|
|
membyAiringToday = item.membyAiringToday || metadata?.membyAiringToday == true,
|
|
membyRecommendationReason = item.membyRecommendationReason
|
|
?: metadata?.membyRecommendationReason,
|
|
membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility,
|
|
)
|
|
|
|
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
|
|
val airingTodayKeys = rows.airingTodayShowKeys()
|
|
if (airingTodayKeys.isEmpty()) return this
|
|
val taggedContinue = continueWatching
|
|
.withAiringTodayItemTags(airingTodayKeys)
|
|
.prioritizeAiringToday()
|
|
return copy(
|
|
rows = rows.withAiringTodayRowTags(airingTodayKeys).map { row ->
|
|
if (row.id == "continue") {
|
|
row.copy(items = row.items.prioritizeAiringToday())
|
|
} else {
|
|
row
|
|
}
|
|
},
|
|
continueWatching = taggedContinue,
|
|
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
|
|
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
|
|
)
|
|
}
|
|
|
|
private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
|
|
firstOrNull { it.id == "sonarr-airing-today" }
|
|
?.items
|
|
.orEmpty()
|
|
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
|
|
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
|
|
.filterTo(mutableSetOf(), String::isNotEmpty)
|
|
|
|
private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRow> =
|
|
map { row -> row.copy(items = row.items.withAiringTodayItemTags(keys)) }
|
|
|
|
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
|
|
map { item ->
|
|
val showName = when {
|
|
item.isEpisode -> item.seriesName
|
|
item.isSeries -> item.name
|
|
else -> null
|
|
}
|
|
if (!item.isTvSchedule && showName != null && showName.showMatchKey() in keys) {
|
|
item.copy(membyAiringToday = true)
|
|
} else {
|
|
item
|
|
}
|
|
}
|
|
|
|
/** Stable partition: today's shows move forward without disturbing recency within groups. */
|
|
private fun List<BaseItem>.prioritizeAiringToday(): List<BaseItem> =
|
|
filter(BaseItem::membyAiringToday) + filterNot(BaseItem::membyAiringToday)
|
|
|
|
private fun String.showMatchKey(): String =
|
|
lowercase().filter(Char::isLetterOrDigit)
|
|
|
|
class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory {
|
|
@Suppress("UNCHECKED_CAST")
|
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
|
|
return HomeViewModel(repository) as T
|
|
}
|
|
}
|