Files
memby/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt
T

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
}
}