Refactor movie page to use use state flow (#1183)

## Description
Refactors the movie page & view model to use a `StateFlow` instead of
many `LiveData` objects. This is in keeping with best practices.

There are no user facing changes.

### Related issues
None

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-04-03 12:44:59 -04:00 committed by GitHub
parent feb8c31791
commit 44d1ac484f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 316 additions and 307 deletions

View file

@ -14,6 +14,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
@ -98,3 +99,13 @@ fun ErrorMessage(
exception = error.exception,
modifier = modifier,
)
@Composable
fun ErrorMessage(
error: DataLoadingState.Error,
modifier: Modifier = Modifier,
) = ErrorMessage(
message = error.message,
exception = error.exception,
modifier = modifier,
)

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -28,10 +29,8 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer
@ -67,11 +66,11 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaStreamType
@ -98,16 +97,7 @@ fun MovieDetails(
viewModel.init()
onPauseOrDispose { }
}
val item by viewModel.item.observeAsState()
val people by viewModel.people.observeAsState(listOf())
val chapters by viewModel.chapters.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val discovered by viewModel.discovered.collectAsState()
val canDelete by viewModel.canDelete.collectAsState()
val state by viewModel.state.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -140,19 +130,21 @@ fun MovieDetails(
onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
is LoadingState.Error -> {
ErrorMessage(state, modifier)
when (val s = state.loading) {
is DataLoadingState.Error -> {
ErrorMessage(s, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
item?.let { movie ->
is DataLoadingState.Success -> {
val unknownStr = stringResource(R.string.unknown)
val movie by rememberUpdatedState(s.data)
val chosenStreams by rememberUpdatedState(state.chosenStreams)
LifecycleResumeEffect(destination.itemId) {
viewModel.maybePlayThemeSong(
destination.itemId,
@ -165,12 +157,7 @@ fun MovieDetails(
MovieDetailsContent(
preferences = preferences,
movie = movie,
chosenStreams = chosenStreams,
people = people,
chapters = chapters,
extras = extras,
trailers = trailers,
similar = similar,
state = state,
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
},
@ -193,7 +180,7 @@ fun MovieDetails(
overviewOnClick = {
overviewDialog =
ItemDetailsDialogInfo(
title = movie.name ?: context.getString(R.string.unknown),
title = movie.name ?: unknownStr,
overview = movie.data.overview,
genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
@ -212,8 +199,8 @@ fun MovieDetails(
favorite = movie.data.userData?.isFavorite ?: false,
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = canDelete,
canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null },
canDelete = state.canDelete,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -230,7 +217,6 @@ fun MovieDetails(
moreDialog = null
},
onChooseTracks = { type ->
viewModel.streamChoiceService
.chooseSource(
movie.data,
@ -262,9 +248,7 @@ fun MovieDetails(
onShowOverview = {
overviewDialog =
ItemDetailsDialogInfo(
title =
movie.name
?: context.getString(R.string.unknown),
title = movie.name ?: unknownStr,
overview = movie.data.overview,
genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
@ -321,17 +305,15 @@ fun MovieDetails(
onClickExtra = { index, extra ->
viewModel.navigateTo(extra.destination)
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
canDelete = canDelete,
deleteOnClick = { showDeleteDialog = movie },
canDelete = state.canDelete,
deleteOnClick = { showDeleteDialog = state.movie },
modifier = modifier,
)
}
}
}
overviewDialog?.let { info ->
ItemDetailsDialog(
info = info,
@ -403,13 +385,7 @@ private const val DISCOVER_ROW = SIMILAR_ROW + 1
fun MovieDetailsContent(
preferences: UserPreferences,
movie: BaseItem,
chosenStreams: ChosenStreams?,
people: List<Person>,
chapters: List<Chapter>,
trailers: List<Trailer>,
extras: List<ExtrasItem>,
similar: List<BaseItem>,
discovered: List<DiscoverItem>,
state: MovieState,
playOnClick: (Duration) -> Unit,
trailerOnClick: (Trailer) -> Unit,
overviewOnClick: () -> Unit,
@ -454,7 +430,7 @@ fun MovieDetailsContent(
MovieDetailsHeader(
preferences = preferences,
movie = movie,
chosenStreams = chosenStreams,
chosenStreams = state.chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
modifier =
@ -481,7 +457,7 @@ fun MovieDetailsContent(
}
}
},
trailers = trailers,
trailers = state.trailers,
trailerOnClick = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
@ -496,7 +472,7 @@ fun MovieDetailsContent(
)
}
}
if (people.isNotEmpty()) {
state.people.letNotEmpty { people ->
item {
PersonRow(
people = people,
@ -515,7 +491,7 @@ fun MovieDetailsContent(
)
}
}
if (chapters.isNotEmpty()) {
state.chapters.letNotEmpty { chapters ->
item {
ChapterRow(
chapters = chapters,
@ -532,7 +508,7 @@ fun MovieDetailsContent(
)
}
}
if (extras.isNotEmpty()) {
state.extras.letNotEmpty { extras ->
item {
ExtrasRow(
extras = extras,
@ -548,7 +524,7 @@ fun MovieDetailsContent(
)
}
}
if (similar.isNotEmpty()) {
state.similar.letNotEmpty { similar ->
item {
val imageHeight =
remember(movie.type) {
@ -587,7 +563,7 @@ fun MovieDetailsContent(
)
}
}
if (discovered.isNotEmpty()) {
state.discovered.letNotEmpty { discovered ->
item {
DiscoverRow(
row =

View file

@ -1,9 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.movie
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.ExtrasItem
@ -30,36 +28,28 @@ import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.combinePair
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest
import timber.log.Timber
import java.util.UUID
@HiltViewModel(assistedFactory = MovieViewModel.Factory::class)
@ -89,94 +79,99 @@ class MovieViewModel
fun create(itemId: UUID): MovieViewModel
}
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val item = MutableLiveData<BaseItem?>(null)
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val canDelete = MutableStateFlow(false)
private val _state = MutableStateFlow(MovieState())
val state: StateFlow<MovieState> = _state
init {
init()
viewModelScope.launchDefault {
item
.asFlow()
.filterNotNull()
.combinePair(userPreferencesService.flow.map { it.appPreferences })
.collectLatest { (item, preferences) ->
canDelete.update { mediaManagementService.canDelete(item, preferences) }
userPreferencesService.flow.collectLatest { preferences ->
_state.update {
val canDelete =
it.movie?.let {
mediaManagementService.canDelete(
it,
preferences.appPreferences,
)
}
it.copy(
canDelete = canDelete ?: false,
)
}
}
}
}
private fun fetchAndSetItem(): Deferred<BaseItem> =
viewModelScope.async(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
private suspend fun getMovie(): BaseItem {
val item =
api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api)
BaseItem(it)
}
this@MovieViewModel.item.setValueOnMain(item)
item
return item
}
fun init(): Job =
viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
val item = fetchAndSetItem().await()
val result =
viewModelScope.launchDefault {
val movie =
try {
getMovie()
} catch (ex: Exception) {
Timber.e(ex, "Failed to fetch movie %s", itemId)
_state.update { it.copy(loading = DataLoadingState.Error(ex)) }
return@launchDefault
}
val chosenStreams =
itemPlaybackRepository.getSelectedTracks(
item.id,
item,
itemId,
movie,
userPreferencesService.getCurrent(),
)
val remoteTrailers = trailerService.getRemoteTrailers(item)
withContext(Dispatchers.Main) {
this@MovieViewModel.item.value = item
chosenStreams.value = result
this@MovieViewModel.trailers.value = remoteTrailers
loading.value = LoadingState.Success
backdropService.submit(item)
val remoteTrailers = trailerService.getRemoteTrailers(movie)
val chapters = Chapter.fromDto(movie.data, api)
_state.update {
it.copy(
loading = DataLoadingState.Success(movie),
chosenStreams = chosenStreams,
trailers = remoteTrailers,
chapters = chapters,
)
}
backdropService.submit(movie)
viewModelScope.launchIO {
trailerService.getLocalTrailers(item).letNotEmpty { localTrailers ->
withContext(Dispatchers.Main) {
this@MovieViewModel.trailers.value = localTrailers + remoteTrailers
trailerService.getLocalTrailers(movie).letNotEmpty { localTrailers ->
_state.update {
it.copy(
trailers = localTrailers + remoteTrailers,
)
}
}
}
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
val people = peopleFavorites.getPeopleFor(movie)
_state.update {
it.copy(
people = people,
)
}
}
viewModelScope.launchIO {
val extras = extrasService.getExtras(item.id)
this@MovieViewModel.extras.setValueOnMain(extras)
val extras = extrasService.getExtras(itemId)
_state.update {
it.copy(
extras = extras,
)
}
}
viewModelScope.launchIO {
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
val results = seerrService.similar(movie).orEmpty()
_state.update {
it.copy(
discovered = results,
)
}
}
withContext(Dispatchers.Main) {
chapters.value = Chapter.fromDto(item.data, api)
}
if (!similar.isInitialized) {
if (state.value.similar.isEmpty()) {
val similar =
api.libraryApi
.getSimilarItems(
@ -187,31 +182,48 @@ class MovieViewModel
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
.map { BaseItem(it) }
_state.update { it.copy(similar = similar) }
}
}
fun setWatched(
itemId: UUID,
played: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
) = viewModelScope.launchDefault {
try {
favoriteWatchManager.setWatched(itemId, played)
fetchAndSetItem()
getMovie().let { movie ->
_state.update {
it.copy(loading = DataLoadingState.Success(movie))
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error updating watch status for movie %s", itemId)
showToast(context, "Something went wrong...")
}
}
fun setFavorite(
itemId: UUID,
favorite: Boolean,
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
) = viewModelScope.launchDefault {
try {
favoriteWatchManager.setFavorite(itemId, favorite)
val item = item.value
fetchAndSetItem()
if (item != null && itemId != item.id) {
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(item)
this@MovieViewModel.people.setValueOnMain(people)
val movie = getMovie()
_state.update {
it.copy(loading = DataLoadingState.Success(movie))
}
if (itemId != movie.id) {
viewModelScope.launchIO {
val people = peopleFavorites.getPeopleFor(movie)
_state.update { it.copy(people = people) }
}
}
} catch (ex: Exception) {
Timber.e(ex, "Error updating favorite %s", itemId)
showToast(context, "Something went wrong...")
}
}
@ -227,9 +239,7 @@ class MovieViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -253,9 +263,7 @@ class MovieViewModel
result?.let {
itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs)
}
withContext(Dispatchers.Main) {
chosenStreams.value = chosen
}
_state.update { it.copy(chosenStreams = chosen) }
}
}
@ -283,14 +291,14 @@ class MovieViewModel
fun clearChosenStreams(chosenStreams: ChosenStreams?) {
viewModelScope.launchIO {
itemPlaybackRepository.deleteChosenStreams(chosenStreams)
item.value?.let { item ->
state.value.movie?.let { item ->
val result =
itemPlaybackRepository.getSelectedTracks(
itemId,
item,
userPreferencesService.getCurrent(),
)
this@MovieViewModel.chosenStreams.setValueOnMain(result)
_state.update { it.copy(chosenStreams = result) }
}
}
}
@ -301,3 +309,17 @@ class MovieViewModel
}
}
}
data class MovieState(
val loading: DataLoadingState<BaseItem> = DataLoadingState.Pending,
val trailers: List<Trailer> = emptyList(),
val people: List<Person> = emptyList(),
val chapters: List<Chapter> = emptyList(),
val extras: List<ExtrasItem> = emptyList(),
val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(),
val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false,
) {
val movie: BaseItem? = (loading as? DataLoadingState.Success<BaseItem>)?.data
}