diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42603452..f18a4afb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -273,11 +273,9 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.androidx.datastore.preferences) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index bd4a129d..80f86954 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault @@ -168,7 +169,7 @@ class MainActivity : AppCompatActivity() { navigationManager.backStack = NavBackStack(startDestination) } - viewModel.serverRepository.currentUser.observe(this) { user -> + viewModel.serverRepository.currentUserFlow.collectLatestIn(lifecycleScope) { user -> if (user?.hasPin == true) { window?.setFlags( WindowManager.LayoutParams.FLAG_SECURE, @@ -435,7 +436,7 @@ class MainActivityViewModel val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val profileProtected = - serverRepository.currentUser.value?.let { + serverRepository.current.value?.user?.let { it.hasPin || it.requireLogin } == true if (prefs.signInAutomatically && !profileProtected) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 502444e1..a281b101 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -88,7 +87,7 @@ class WholphinDreamService : setViewTreeLifecycleOwner(this@WholphinDreamService) setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) setContent { - val user by serverRepository.currentUser.observeAsState() + val user by serverRepository.currentUserFlow.collectAsState(null) if (user != null) { var prefs by remember { mutableStateOf(null) } LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 3f8dc198..80a2c45f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -33,7 +33,7 @@ class ItemPlaybackRepository item: BaseItem, prefs: UserPreferences, ): ChosenStreams? = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") @@ -91,7 +91,7 @@ class ItemPlaybackRepository sourceId: UUID, ): ItemPlayback? = withContext(Dispatchers.IO) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val itemPlayback = ItemPlayback( userId = user.rowId, @@ -168,7 +168,7 @@ class ItemPlaybackRepository val toSave = if (itemPlayback.userId < 0) { val userRowId = - serverRepository.currentUser.value + serverRepository.currentUser ?.rowId ?.takeIf { it >= 0 } ?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user") @@ -184,7 +184,7 @@ class ItemPlaybackRepository itemId: UUID, trackIndex: Int, ): ItemTrackModification? = - serverRepository.currentUser.value?.rowId?.let { userId -> + serverRepository.currentUser?.rowId?.let { userId -> itemPlaybackDao.getTrackModifications(userId, itemId, trackIndex) } @@ -193,7 +193,7 @@ class ItemPlaybackRepository trackIndex: Int, delay: Duration, ) { - serverRepository.currentUser.value?.rowId?.let { userId -> + serverRepository.currentUser?.rowId?.let { userId -> Timber.v("Saving track mod item=%s, track=%s, delay=%s", itemId, trackIndex, delay) itemPlaybackDao.saveItem( ItemTrackModification( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 25be2d02..05cc9bd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -4,18 +4,18 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import androidx.datastore.core.DataStore -import androidx.lifecycle.LiveData -import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin @@ -46,14 +46,17 @@ class ServerRepository val userPreferencesDataStore: DataStore, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { - private var _current = EqualityMutableLiveData(null) - val current: LiveData = _current + private var _current = MutableStateFlow(null) + val current: StateFlow = _current - private var _currentUserDto = EqualityMutableLiveData(null) - val currentUserDto: LiveData = _currentUserDto + private var _currentUserDto = MutableStateFlow(null) + val currentUserDto: UserDto? get() = _currentUserDto.value + val currentUserDtoFlow: StateFlow get() = _currentUserDto - val currentServer: LiveData get() = _current.map { it?.server } - val currentUser: LiveData get() = _current.map { it?.user } + val currentServer: JellyfinServer? get() = _current.value?.server + val currentServerFlow: Flow get() = _current.map { it?.server } + val currentUser: JellyfinUser? get() = _current.value?.user + val currentUserFlow: Flow get() = _current.map { it?.user } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -65,7 +68,7 @@ class ServerRepository serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) - _current.setValueOnMain(null) + _current.value = null } /** @@ -127,7 +130,7 @@ class ServerRepository userId: UUID?, ): CurrentUser? { if (serverId == null || userId == null) { - _current.setValueOnMain(null) + _current.value = null return null } val serverAndUsers = @@ -218,7 +221,7 @@ class ServerRepository } suspend fun removeUser(user: JellyfinUser) { - if (currentUser.value?.id == user.id) { + if (current.value?.user?.id == user.id) { withContext(Dispatchers.Main) { _current.value = null } @@ -237,7 +240,7 @@ class ServerRepository } suspend fun removeServer(server: JellyfinServer) { - if (currentServer.value?.id == server.id) { + if (current.value?.server?.id == server.id) { withContext(Dispatchers.Main) { _current.value = null } @@ -274,18 +277,19 @@ class ServerRepository ) { val newUser = user.copy(pin = pin, requireLogin = requireLogin) val updatedUser = serverDao.addOrUpdateUser(newUser) - if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { + val cur = current.value + if (cur?.user?.id == updatedUser.id && cur.server?.id == user.serverId) { // Updating current user, so push out the change current.value?.let { val newCurrent = it.copy(user = updatedUser) - _current.setValueOnMain(newCurrent) + _current.value = newCurrent } } } suspend fun authorizeQuickConnect(code: String): Boolean = withContext(ioDispatcher) { - val userId = currentUser.value?.id + val userId = current.value?.user?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") throw IllegalStateException("Must be logged in to authorize Quick Connect") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index 9c4cfa5c..382ac22e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -2,10 +2,12 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext @@ -152,7 +154,7 @@ class DatePlayedInvalidationService private val activity = (context as AppCompatActivity) init { - serverRepository.current.observe(activity) { + serverRepository.current.collectLatestIn(activity.lifecycleScope) { datePlayedService.invalidateAll() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index c40a1057..af3df181 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -265,8 +265,8 @@ class HomeSettingsService */ suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") - val user = serverRepository.currentUser.value?.takeIf { it.id == userId } - val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId } + val user = serverRepository.currentUser?.takeIf { it.id == userId } + val userDto = serverRepository.currentUserDto?.takeIf { it.id == userId } val libraries = if (user != null) { navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) @@ -679,7 +679,7 @@ class HomeSettingsService val genreImages = getGenreImageMap( api = api, - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, scope = scope, imageUrlService = imageUrlService, genres = genreIds, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 5c562cd1..04706b94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -14,6 +14,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -94,7 +95,7 @@ class LatestNextUpSchedulerService internal var dispatcher: CoroutineDispatcher = Dispatchers.IO init { - serverRepository.current.observe(activity) { user -> + serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> Timber.v("New user %s", user?.user?.id) if (user == null) { workManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index 5a6d8ff0..684d56b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -63,7 +63,7 @@ class MediaManagementService return enabled && item.canDelete && if (item.type == BaseItemKind.RECORDING) { - serverRepository.currentUserDto.value + serverRepository.currentUserDto ?.policy ?.enableLiveTvManagement == true } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt index d1a1543a..eeae86d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -62,7 +62,7 @@ class MediaReportService .content.mediaSources val sourcesJson = json.encodeToString(sources) val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences - val serverVersion = serverRepository.currentServer.value?.serverVersion + val serverVersion = serverRepository.currentServer?.serverVersion val deviceProfile = deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) val deviceProfileJson = json.encodeToString(deviceProfile) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 6eddb51c..c339a8dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -159,7 +159,7 @@ class MusicService val items = api.instantMixApi .getInstantMixFromItem( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, limit = 200, fields = DefaultItemFields, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 68486430..cbf2e955 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.services import android.content.Context -import androidx.lifecycle.asFlow import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -58,9 +57,8 @@ class NavDrawerService init { // Handle updating the nav drawer when the user changes - serverRepository.currentUser - .asFlow() - .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> + serverRepository.currentUserFlow + .combine(serverRepository.currentUserDtoFlow) { user, userDto -> Pair(user, userDto) }.onEach { (user, userDto) -> Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 783e8870..b0a7e106 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -93,7 +93,7 @@ class PlaylistCreator val request = filter.applyTo( GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = playlistId, fields = DefaultItemFields, startIndex = startIndex, @@ -324,7 +324,7 @@ class PlaylistCreator name: String, initialItems: List, ): UUID? = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> api.playlistsApi .createPlaylist( CreatePlaylistDto( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index e2086c29..90d9526b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -94,7 +94,7 @@ class SeerrServerRepository server = seerrServerDao.getServer(url) } server?.server?.let { server -> - serverRepository.currentUser.value?.let { jellyfinUser -> + serverRepository.currentUser?.let { jellyfinUser -> // TODO test api key val user = SeerrUser( @@ -126,7 +126,7 @@ class SeerrServerRepository server = seerrServerDao.getServer(url) } server?.server?.let { server -> - serverRepository.currentUser.value?.let { jellyfinUser -> + serverRepository.currentUser?.let { jellyfinUser -> // TODO Need to update server early so that cookies are saved seerrApi.update(server.url, null) val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt index 979c90bd..ec77c65c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerEventListener.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ActivityContext @@ -42,7 +43,7 @@ class ServerEventListener init { activity.lifecycle.addObserver(this) - serverRepository.current.observe(activity) { + serverRepository.current.collectLatestIn(activity.lifecycleScope) { Timber.d("New user/server: %s", it) listenJob?.cancel() if (it != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 05eac34e..3a3a3e9c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -30,7 +30,7 @@ class StreamChoiceService private val serverRepository: ServerRepository, private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, ) { - private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration + private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration suspend fun updateAudio( dto: BaseItemDto, @@ -58,7 +58,7 @@ class StreamChoiceService ) { val seriesId = dto.seriesId if (seriesId != null) { - val userId = serverRepository.currentUser.value!!.rowId + val userId = serverRepository.currentUser!!.rowId val currentPlc = playbackLanguageChoiceDao.get(userId, seriesId) ?: PlaybackLanguageChoice(userId, seriesId, dto.id) @@ -70,7 +70,7 @@ class StreamChoiceService suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = dto.seriesId?.let { - playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) + playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) } /** @@ -107,7 +107,13 @@ class StreamChoiceService plc: PlaybackLanguageChoice?, prefs: UserPreferences, ): MediaStream? { - val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get(serverRepository.currentUser.value!!.rowId, it) } + val plc = + plc ?: seriesId?.let { + playbackLanguageChoiceDao.get( + serverRepository.currentUser!!.rowId, + it, + ) + } return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } chooseAudioStream(candidates, itemPlayback, plc, prefs) @@ -158,7 +164,7 @@ class StreamChoiceService val plc = plc ?: seriesId?.let { playbackLanguageChoiceDao.get( - serverRepository.currentUser.value!!.rowId, + serverRepository.currentUser!!.rowId, it, ) } @@ -194,7 +200,7 @@ class StreamChoiceService } val itemPlayback = ItemPlayback( - userId = serverRepository.currentUser.value!!.rowId, + userId = serverRepository.currentUser!!.rowId, itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution subtitleIndex = TrackIndex.ONLY_FORCED, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt index e6d1b0db..bed98436 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.services -import androidx.lifecycle.asFlow import androidx.work.Constraints import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType @@ -52,8 +51,7 @@ class SuggestionService parentId: UUID, itemKind: BaseItemKind, ): Flow { - return serverRepository.currentUser - .asFlow() + return serverRepository.currentUserFlow .flatMapLatest { user -> val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) val cachedSuggestions = cache.get(userId, parentId, itemKind) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index 86bab2f3..da6ffef9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -8,15 +8,14 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -35,6 +34,7 @@ class SuggestionsSchedulerService @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, private val workManager: WorkManager, + @param:IoDispatcher private val dispatcher: CoroutineDispatcher, ) { private val activity = (context as? AppCompatActivity) @@ -42,18 +42,18 @@ class SuggestionsSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - // Exposed for testing - internal var dispatcher: CoroutineDispatcher = Dispatchers.IO internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } init { - serverRepository.current.observe(activity) { user -> - Timber.v("New user %s", user?.user?.id) - if (user == null) { - workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) - } else { - activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { - scheduleWork(user.user.id, user.server.id) + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + serverRepository.current.collect { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 7c7fce11..0fe6be1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -22,7 +22,7 @@ class UserPreferencesService val flow = preferencesDataStore.data.map { UserPreferences(it) } suspend fun getCurrent(): UserPreferences = - serverRepository.currentUserDto.value!!.configuration.let { userConfig -> + serverRepository.currentUserDto!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() UserPreferences( appPrefs, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 352b8439..2565f077 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat -import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.SeerrServerDao @@ -39,7 +38,7 @@ class UserSwitchListener init { context as AppCompatActivity context.lifecycleScope.launchDefault { - serverRepository.currentUser.asFlow().collect { user -> + serverRepository.currentUserFlow.collect { user -> Timber.d("New user") seerrServerRepository.clear() homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 38071cac..d1ed9eac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -116,7 +116,7 @@ object AppModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.currentUser.value?.accessToken?.let { token -> + serverRepository.current.value?.user?.accessToken?.let { token -> request .newBuilder() .addHeader( @@ -170,7 +170,10 @@ object AppModule { appPreference: DataStore, @IoCoroutineScope scope: CoroutineScope, ) = object : RememberTabManager { - fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId" + fun key(itemId: String): String = + serverRepository.current.value.let { + "${it?.server?.id}_${it?.user?.id}_$itemId" + } override fun getRememberedTab( preferences: UserPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index fb40c008..336be1fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -13,6 +13,7 @@ import androidx.work.WorkManager import androidx.work.await import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.qualifiers.ActivityContext @@ -43,7 +44,7 @@ class TvProviderSchedulerService context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) init { - serverRepository.current.observe(activity) { user -> + serverRepository.current.collectLatestIn(activity.lifecycleScope) { user -> workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) if (supportsTvProvider) { if (user != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 4d69fe0c..2c633c35 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.MutableLiveData import androidx.media3.common.Player import coil3.request.ErrorResult import com.github.damontecres.wholphin.data.model.BaseItem @@ -397,11 +396,6 @@ fun CoroutineScope.launchDefault( */ fun UUID.toServerString() = this.toString().replace("-", "") -suspend fun MutableLiveData.setValueOnMain(value: T) = - withContext(Dispatchers.Main) { - this@setValueOnMain.value = value - } - fun equalsNotNull( a: Any?, b: Any?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index f6fde818..00b43336 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -66,7 +66,7 @@ fun CollectionFolderGrid( preferences: UserPreferences, item: BaseItem?, title: String, - loadingState: DataLoadingState>, + items: DataLoadingState>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -91,7 +91,7 @@ fun CollectionFolderGrid( ) { val context = LocalContext.current - val pager = (loadingState as? DataLoadingState.Success)?.data + val pager = (items as? DataLoadingState.Success)?.data var showHeader by rememberSaveable { mutableStateOf(true) } val headerRowFocusRequester = remember { FocusRequester() } @@ -119,7 +119,7 @@ fun CollectionFolderGrid( modifier = Modifier.fillMaxSize(), ) { CollectionFolderHeader( - showHeader = showHeader || loadingState !is DataLoadingState.Success, + showHeader = showHeader || items !is DataLoadingState.Success, showTitle = showTitle, playEnabled = playEnabled && pager?.isNotEmpty() == true, title = title, @@ -147,7 +147,7 @@ fun CollectionFolderGrid( .padding(HeaderUtils.padding), ) } - when (val state = loadingState) { + when (val state = items) { DataLoadingState.Pending, DataLoadingState.Loading, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 0543646f..bb4378fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -82,7 +82,7 @@ fun CollectionFolderList( preferences: UserPreferences, item: BaseItem?, title: String, - loadingState: DataLoadingState>, + items: DataLoadingState>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -108,7 +108,7 @@ fun CollectionFolderList( val context = LocalContext.current val scope = rememberCoroutineScope() - val pager = (loadingState as? DataLoadingState.Success)?.data + val pager = (items as? DataLoadingState.Success)?.data var showHeader by rememberSaveable { mutableStateOf(true) } val headerRowFocusRequester = remember { FocusRequester() } val showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME @@ -147,7 +147,7 @@ fun CollectionFolderList( Column(modifier = modifier) { CollectionFolderHeader( - showHeader = showHeader || loadingState !is DataLoadingState.Success, + showHeader = showHeader || items !is DataLoadingState.Success, showTitle = showTitle, playEnabled = playEnabled && pager?.isNotEmpty() == true, title = title, @@ -162,7 +162,7 @@ fun CollectionFolderList( onFilterChange = onFilterChange, modifier = Modifier.focusRequester(headerRowFocusRequester), ) - when (val state = loadingState) { + when (val state = items) { DataLoadingState.Pending, DataLoadingState.Loading, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 7a96d93e..3c7cbac2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -19,12 +18,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme @@ -56,15 +54,12 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.util.FilterUtils @@ -74,6 +69,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -81,12 +77,15 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +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.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -95,6 +94,7 @@ import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID @@ -104,7 +104,7 @@ class CollectionFolderViewModel @AssistedInject constructor( private val savedStateHandle: SavedStateHandle, - api: ApiClient, + private val api: ApiClient, @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, @@ -117,13 +117,13 @@ class CollectionFolderViewModel private val musicService: MusicService, val streamChoiceService: StreamChoiceService, val mediaReportService: MediaReportService, - @Assisted itemId: String, + @Assisted val itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @Assisted private val collectionFilter: CollectionFolderFilter, @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, @Assisted defaultViewOptions: ViewOptions, - ) : ItemViewModel(api) { + ) : ViewModel() { @AssistedFactory interface Factory { fun create( @@ -136,11 +136,8 @@ class CollectionFolderViewModel ): CollectionFolderViewModel } - val loading = MutableLiveData>>(DataLoadingState.Loading) - val backgroundLoading = MutableLiveData(LoadingState.Loading) - val sortAndDirection = MutableLiveData() - val filter = MutableLiveData(GetItemsFilter()) - val viewOptions = MutableStateFlow(defaultViewOptions) + private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) + val state: StateFlow = _state var position: Int get() = savedStateHandle.get("position") ?: 0 @@ -150,19 +147,22 @@ class CollectionFolderViewModel init { viewModelScope.launchIO { - super.itemId = itemId try { val item = - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + api.userLibraryApi + .getItem(itemId.toUUID()) + .content + .let(::BaseItem) val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } - this@CollectionFolderViewModel.viewOptions.value = - libraryDisplayInfo?.viewOptions ?: defaultViewOptions + _state.update { + it.copy( + viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + ) + } val sortAndDirection = if (collectionFilter.useSavedLibraryDisplayInfo) { @@ -178,12 +178,13 @@ class CollectionFolderViewModel collectionFilter.filter } + _state.update { it.copy(item = DataLoadingState.Success(item)) } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) .join() // onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") - loading.setValueOnMain(DataLoadingState.Error(ex)) + _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } mediaManagementService.deletedItemFlow @@ -200,7 +201,7 @@ class CollectionFolderViewModel ) { try { val pager = - ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + ((state.value.items as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) position.let { Timber.v("Item deleted: position=%s, id=%s", it, itemId) val item = pager?.get(it) @@ -218,12 +219,12 @@ class CollectionFolderViewModel } private fun saveLibraryDisplayInfo( - newFilter: GetItemsFilter = this.filter.value!!, - newSort: SortAndDirection = this.sortAndDirection.value!!, - viewOptions: ViewOptions? = this.viewOptions.value, + newFilter: GetItemsFilter = state.value.filter, + newSort: SortAndDirection = state.value.sortAndDirection, + viewOptions: ViewOptions? = state.value.viewOptions, ) { if (collectionFilter.useSavedLibraryDisplayInfo) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> viewModelScope.launchIO { val libraryDisplayInfo = LibraryDisplayInfo( @@ -241,7 +242,7 @@ class CollectionFolderViewModel } fun saveViewOptions(viewOptions: ViewOptions) { - this.viewOptions.value = viewOptions + _state.update { it.copy(viewOptions = viewOptions) } viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) if (!viewOptions.showBackdrop) { @@ -255,8 +256,14 @@ class CollectionFolderViewModel recursive: Boolean, ) { Timber.v("onFilterChange: filter=%s", newFilter) - saveLibraryDisplayInfo(newFilter, sortAndDirection.value!!) - loadResults(false, sortAndDirection.value!!, recursive, newFilter, useSeriesForPrimary) + saveLibraryDisplayInfo(newFilter) + loadResults( + false, + state.value.sortAndDirection, + recursive, + newFilter, + useSeriesForPrimary, + ) } fun onSortChange( @@ -281,21 +288,23 @@ class CollectionFolderViewModel filter: GetItemsFilter, useSeriesForPrimary: Boolean, ) = viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + _state.update { + it.copy( + items = DataLoadingState.Loading, + backgroundLoading = LoadingState.Loading, + sortAndDirection = sortAndDirection, + filter = filter, + ) } try { val newPager = createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success + _state.update { + it.copy( + items = DataLoadingState.Success(newPager), + backgroundLoading = LoadingState.Success, + ) } } catch (ex: Exception) { Timber.e( @@ -304,9 +313,7 @@ class CollectionFolderViewModel sortAndDirection, filter, ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + _state.update { it.copy(items = DataLoadingState.Error(ex)) } } } @@ -359,7 +366,7 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, ): GetItemsRequest { - val item = item.value + val item = state.value.item.successValue val includeItemTypes = item ?.data @@ -413,18 +420,15 @@ class CollectionFolderViewModel suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = FilterUtils.getFilterOptionValues( api, - serverRepository.currentUser.value?.id, - itemUuid, + serverRepository.currentUser?.id, + itemId.toUUID(), filterOption, ) suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { - val sort = sortAndDirection.value - val filter = filter.value - if (sort == null || filter == null) { - return@withContext null - } + val sort = state.value.sortAndDirection + val filter = state.value.filter val request = createGetItemsRequest( sortAndDirection = sort, @@ -447,7 +451,7 @@ class CollectionFolderViewModel played: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - (loading.value as? DataLoadingState.Success)?.let { + (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) } } @@ -458,7 +462,7 @@ class CollectionFolderViewModel favorite: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - (loading.value as? DataLoadingState.Success)?.let { + (state.value.items as? DataLoadingState.Success)?.let { (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) } } @@ -480,9 +484,9 @@ class CollectionFolderViewModel fun onResumePage() { viewModelScope.launchIO { - item.value?.let { - Timber.v("onResumePage: %s", loading.value!!::class) - if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + state.value.item.successValue?.let { + Timber.v("onResumePage: %s", state.value.items::class) + if (it.type == BaseItemKind.BOX_SET && state.value.items !is DataLoadingState.Error) { themeSongPlayer.playThemeFor(it.id) } } @@ -511,6 +515,15 @@ class CollectionFolderViewModel ) = addToQueue(api, musicService, item, index) } +data class CollectionFolderState( + val item: DataLoadingState = DataLoadingState.Loading, + val items: DataLoadingState> = DataLoadingState.Loading, + val backgroundLoading: LoadingState = LoadingState.Loading, + val sortAndDirection: SortAndDirection = SortAndDirection.DEFAULT, + val filter: GetItemsFilter = GetItemsFilter(), + val viewOptions: ViewOptions, +) + /** * Shows a collection folder as a grid * @@ -623,18 +636,12 @@ fun CollectionFolderView( ) }, ) { - val context = LocalContext.current - val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) - val filter by viewModel.filter.observeAsState(initialFilter.filter) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - val viewOptions by viewModel.viewOptions.collectAsState() + val state by viewModel.state.collectAsState() var showContextMenu by remember { mutableStateOf(null) } var overviewDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showViewOptions by rememberSaveable { mutableStateOf(false) } val contextActions = @@ -689,12 +696,12 @@ fun CollectionFolderView( actions.onClickPlayAll ?: { shuffle -> itemId.toUUIDOrNull()?.let { val destination = - if (item?.type == BaseItemKind.PHOTO_ALBUM) { + if (state.item.successValue?.type == BaseItemKind.PHOTO_ALBUM) { Destination.Slideshow( parentId = it, index = 0, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, + filter = CollectionFolderFilter(filter = state.filter), + sortAndDirection = state.sortAndDirection, recursive = true, startSlideshow = true, ) @@ -704,8 +711,8 @@ fun CollectionFolderView( startIndex = 0, shuffle = shuffle, recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, + sortAndDirection = state.sortAndDirection, + filter = state.filter, ) } viewModel.navigateTo(destination) @@ -719,8 +726,8 @@ fun CollectionFolderView( Destination.Slideshow( parentId = item.id, index = index, - filter = CollectionFolderFilter(filter = filter), - sortAndDirection = sortAndDirection, + filter = CollectionFolderFilter(filter = state.filter), + sortAndDirection = state.sortAndDirection, recursive = true, startSlideshow = true, ) @@ -732,7 +739,7 @@ fun CollectionFolderView( ) } - when (val state = loading) { + when (val st = state.item) { DataLoadingState.Loading, DataLoadingState.Pending, -> { @@ -742,6 +749,7 @@ fun CollectionFolderView( is DataLoadingState.Error, is DataLoadingState.Success<*>, -> { + val item = st.successValue val title = initialFilter.nameOverride ?: item?.name @@ -755,23 +763,23 @@ fun CollectionFolderView( viewModel.release() } } - if (viewOptions.type == ViewOptionsType.GRID) { + if (state.viewOptions.type == ViewOptionsType.GRID) { CollectionFolderGrid( preferences = preferences, initialPosition = viewModel.position, item = item, title = title, - loadingState = state as DataLoadingState>, - sortAndDirection = sortAndDirection!!, + items = state.items, + sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), focusRequesterOnEmpty = focusRequesterOnEmpty, onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, onSortChange = { - viewModel.onSortChange(it, recursive, filter) + viewModel.onSortChange(it, recursive, state.filter) }, filterOptions = filterOptions, - currentFilter = filter, + currentFilter = state.filter, onFilterChange = { viewModel.onFilterChange(it, recursive) }, @@ -785,7 +793,7 @@ fun CollectionFolderView( positionCallback?.invoke(columns, position) }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, + viewOptions = state.viewOptions, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, onClickPlay = gridActions.onClickPlayRemoteButton!!, @@ -798,17 +806,17 @@ fun CollectionFolderView( initialPosition = viewModel.position, item = item, title = title, - loadingState = state as DataLoadingState>, - sortAndDirection = sortAndDirection!!, + items = state.items, + sortAndDirection = state.sortAndDirection, modifier = Modifier.fillMaxSize(), focusRequesterOnEmpty = focusRequesterOnEmpty, onClickItem = gridActions.onClickItem, onLongClickItem = gridActions.onLongClickItem!!, onSortChange = { - viewModel.onSortChange(it, recursive, filter) + viewModel.onSortChange(it, recursive, state.filter) }, filterOptions = filterOptions, - currentFilter = filter, + currentFilter = state.filter, onFilterChange = { viewModel.onFilterChange(it, recursive) }, @@ -822,7 +830,7 @@ fun CollectionFolderView( positionCallback?.invoke(columns, position) }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, + viewOptions = state.viewOptions, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, onClickPlay = gridActions.onClickPlayRemoteButton!!, @@ -832,7 +840,7 @@ fun CollectionFolderView( } AnimatedVisibility( - backgroundLoading == LoadingState.Loading, + state.backgroundLoading == LoadingState.Loading, modifier = Modifier .align(Alignment.Center) @@ -854,7 +862,7 @@ fun CollectionFolderView( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -887,11 +895,11 @@ fun CollectionFolderView( } AnimatedVisibility(showViewOptions) { ViewOptionsDialog( - viewOptions = viewOptions, + viewOptions = state.viewOptions, defaultViewOptions = defaultViewOptions, onDismissRequest = { showViewOptions = false - viewModel.saveViewOptions(viewOptions) + viewModel.saveViewOptions(state.viewOptions) }, onViewOptionsChange = viewModel::saveViewOptions, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 17fdcd37..ac19cf30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository @@ -29,12 +28,11 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -44,7 +42,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -80,54 +80,63 @@ class GenreViewModel ): GenreViewModel } - val item = MutableLiveData(null) - val loading = MutableLiveData(LoadingState.Pending) - val genres = MutableLiveData>(listOf()) + private val _state = MutableStateFlow(GenreGridState()) + val state: StateFlow = _state fun init(cardWidthPx: Int) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - this@GenreViewModel.item.setValueOnMain(item) - val request = - GetGenresRequest( - userId = serverRepository.currentUser.value?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val genres = - GetGenresRequestHandler - .execute(api, request) - .content.items - .map { - Genre(it.id, it.name ?: "", null) + _state.update { it.copy(item = DataLoadingState.Loading) } + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) } - withContext(Dispatchers.Main) { - this@GenreViewModel.genres.value = genres - loading.value = LoadingState.Success - } - val genreToUrl = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser.value?.id, - scope = viewModelScope, - imageUrlService = imageUrlService, - genres = genres.map { it.id }, - parentId = itemId, - includeItemTypes = includeItemTypes, - cardWidthPx = cardWidthPx, - ) - val genresWithImages = - genres.map { + val request = + GetGenresRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val genres = + GetGenresRequestHandler + .execute(api, request) + .content.items + .map { + Genre(it.id, it.name ?: "", null) + } + _state.update { it.copy( - imageUrl = genreToUrl[it.id], + item = DataLoadingState.Success(item), + genres = genres, ) } - this@GenreViewModel.genres.setValueOnMain(genresWithImages) + val genreToUrl = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser?.id, + scope = viewModelScope, + imageUrlService = imageUrlService, + genres = genres.map { it.id }, + parentId = itemId, + includeItemTypes = includeItemTypes, + cardWidthPx = cardWidthPx, + ) + val genresWithImages = + genres.map { + it.copy( + imageUrl = genreToUrl[it.id], + ) + } + _state.update { + it.copy( + genres = genresWithImages, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching genres") + _state.update { it.copy(item = DataLoadingState.Error(ex)) } + } } } @@ -146,6 +155,11 @@ class GenreViewModel } } +data class GenreGridState( + val item: DataLoadingState = DataLoadingState.Pending, + val genres: List = emptyList(), +) + data class GenreCacheKey( val userId: UUID?, val parentId: UUID, @@ -266,34 +280,33 @@ fun GenreCardGrid( OneTimeLaunchedEffect { viewModel.init(cardWidthPx) } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val genres by viewModel.genres.observeAsState(listOf()) + val state by viewModel.state.collectAsState() val gridFocusRequester = remember { FocusRequester() } - when (val st = loading) { - LoadingState.Pending, - LoadingState.Loading, + when (val st = state.item) { + DataLoadingState.Pending, + DataLoadingState.Loading, -> { LoadingPage(modifier.focusable()) } - is LoadingState.Error -> { + is DataLoadingState.Error -> { ErrorMessage(st, modifier.focusable()) } - LoadingState.Success -> { + is DataLoadingState.Success -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - val item by viewModel.item.observeAsState(null) + val item = st.data CardGrid( - pager = genres, + pager = state.genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( createGenreDestination( genreId = genre.id, genreName = genre.name, parentId = itemId, - parentName = item?.title, + parentName = item.title, includeItemTypes = includeItemTypes, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 12c7c56e..8c2b95da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -5,15 +5,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme @@ -26,16 +25,18 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.RequestHandler import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber @HiltViewModel(assistedFactory = ItemGridViewModel.Factory::class) class ItemGridViewModel @@ -45,8 +46,8 @@ class ItemGridViewModel private val navigationManager: NavigationManager, @Assisted private val destination: Destination.ItemGrid<*>, ) : ViewModel() { - val loading = MutableLiveData(LoadingState.Loading) - val items = MutableLiveData>(listOf()) + private val _state = MutableStateFlow(ItemGridState()) + val state: StateFlow = _state @AssistedFactory interface Factory { @@ -54,22 +55,28 @@ class ItemGridViewModel } init { - viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching items")) { - val request = destination.request as Any - val pager = - ApiRequestPager( - api, - request, - destination.requestHandler as RequestHandler, - viewModelScope, - useSeriesForPrimary = true, - ).init() - if (pager.isNotEmpty()) { - pager.getBlocking(0) - } - withContext(Dispatchers.Main) { - this@ItemGridViewModel.items.value = pager - this@ItemGridViewModel.loading.value = LoadingState.Success + viewModelScope.launchIO { + try { + val request = destination.request as Any + val pager = + ApiRequestPager( + api, + request, + destination.requestHandler as RequestHandler, + viewModelScope, + useSeriesForPrimary = true, + ).init() + if (pager.isNotEmpty()) { + pager.getBlocking(0) + } + _state.update { + it.copy(items = DataLoadingState.Success(pager)) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching items") + _state.update { it.copy(items = DataLoadingState.Error(ex)) } } } } @@ -79,6 +86,10 @@ class ItemGridViewModel } } +data class ItemGridState( + val items: DataLoadingState> = DataLoadingState.Pending, +) + /** * Display a grid of a list of arbitrary items [com.github.damontecres.wholphin.data.ExtrasItem] */ @@ -91,20 +102,19 @@ fun ItemGrid( creationCallback = { it.create(destination) }, ), ) { - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val items by viewModel.items.observeAsState(listOf()) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + val state by viewModel.state.collectAsState() + when (val st = state.items) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { + is DataLoadingState.Success> -> { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() @@ -118,7 +128,7 @@ fun ItemGrid( modifier = Modifier.fillMaxWidth(), ) CardGrid( - pager = items, + pager = st.data, onClickItem = { index: Int, item: BaseItem -> // TODO handle more types viewModel.navigateTo(Destination.Playback(item.id, 0)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 85328920..88e86197 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -36,7 +35,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -351,7 +349,7 @@ fun RecommendedContent( var showContextMenu by remember { mutableStateOf(null) } var overviewDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() OneTimeLaunchedEffect { viewModel.init() @@ -451,7 +449,7 @@ fun RecommendedContent( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index a0a7d9e4..cf3e7ed9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository @@ -29,23 +28,25 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.StudioCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetStudiosRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = StudioViewModel.Factory::class) @@ -67,41 +68,46 @@ class StudioViewModel ): StudioViewModel } - val item = MutableLiveData(null) - val loading = MutableLiveData(LoadingState.Pending) - val studios = MutableLiveData>(listOf()) + private val _state = MutableStateFlow(StudioGridState()) + val state: StateFlow = _state fun init(cardWidthPx: Int) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { - val item = - api.userLibraryApi.getItem(itemId = itemId).content.let { - BaseItem(it, false) - } - this@StudioViewModel.item.setValueOnMain(item) - val request = - GetStudiosRequest( - userId = serverRepository.currentUser.value?.id, - parentId = itemId, - fields = SlimItemFields, - includeItemTypes = includeItemTypes, - ) - val studios = - GetStudiosRequestHandler - .execute(api, request) - .content.items - .map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - fillWidth = cardWidthPx, - ) - Studio(it.id, it.name ?: "", imageUrl) + _state.update { it.copy(item = DataLoadingState.Loading) } + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) } - withContext(Dispatchers.Main) { - this@StudioViewModel.studios.value = studios - loading.value = LoadingState.Success + val request = + GetStudiosRequest( + userId = serverRepository.currentUser?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val studios = + GetStudiosRequestHandler + .execute(api, request) + .content.items + .map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + fillWidth = cardWidthPx, + ) + Studio(it.id, it.name ?: "", imageUrl) + } + _state.update { + it.copy( + item = DataLoadingState.Success(item), + studios = studios, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching studios") + _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } } @@ -110,7 +116,7 @@ class StudioViewModel withContext(Dispatchers.IO) { val request = GetStudiosRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = itemId, nameLessThan = letter.toString(), limit = 0, @@ -133,6 +139,11 @@ data class Studio( override val sortName: String get() = name } +data class StudioGridState( + val item: DataLoadingState = DataLoadingState.Pending, + val studios: List = emptyList(), +) + @Composable fun StudioCardGrid( itemId: UUID, @@ -162,34 +173,32 @@ fun StudioCardGrid( OneTimeLaunchedEffect { viewModel.init(cardWidthPx) } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val studios by viewModel.studios.observeAsState(listOf()) + val state by viewModel.state.collectAsState() val gridFocusRequester = remember { FocusRequester() } - when (val st = loading) { - LoadingState.Pending, - LoadingState.Loading, + when (val st = state.item) { + DataLoadingState.Pending, + DataLoadingState.Loading, -> { LoadingPage(modifier.focusable()) } - is LoadingState.Error -> { + is DataLoadingState.Error -> { ErrorMessage(st, modifier.focusable()) } - LoadingState.Success -> { + is DataLoadingState.Success -> { Box(modifier = modifier) { LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } - val item by viewModel.item.observeAsState(null) CardGrid( - pager = studios, + pager = state.studios, onClickItem = { _, studio -> viewModel.navigationManager.navigateTo( createStudioDestination( studioId = studio.id, name = studio.name, parentId = itemId, - parentName = item?.title, + parentName = st.data.title, includeItemTypes = includeItemTypes, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index 5c4721c5..c2b8c94a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -2,18 +2,17 @@ package com.github.damontecres.wholphin.ui.data import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID @@ -30,16 +29,17 @@ class AddPlaylistViewModel @param:ApplicationContext private val context: Context, private val playlistCreator: PlaylistCreator, ) : ViewModel() { - val playlistState = MutableLiveData(PlaylistLoadingState.Pending) + val playlistState = MutableStateFlow(PlaylistLoadingState.Pending) fun loadPlaylists(mediaType: MediaType?) { viewModelScope.launchIO { - this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading) + this@AddPlaylistViewModel.playlistState.value = PlaylistLoadingState.Loading try { val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope) - this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists)) + this@AddPlaylistViewModel.playlistState.value = + PlaylistLoadingState.Success(playlists) } catch (ex: Exception) { - playlistState.setValueOnMain(PlaylistLoadingState.Error(ex)) + playlistState.value = PlaylistLoadingState.Error(ex) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 04adf7eb..f1e575b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -8,8 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,7 +21,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R @@ -41,10 +40,10 @@ import com.github.damontecres.wholphin.ui.detail.livetv.TvGuideGrid import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import java.util.UUID @@ -61,16 +60,16 @@ class LiveTvCollectionViewModel val backdropService: BackdropService, ) : ViewModel(), RememberTabManager by rememberTabManager { - val recordingFolders = MutableLiveData>() + val recordingFolders = MutableStateFlow>(emptyList()) init { viewModelScope.launchIO { val folders = api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser.value?.id) + .getRecordingFolders(userId = serverRepository.currentUser?.id) .content.items .map { TabId(it.name ?: "Recordings", it.id) } - this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) + recordingFolders.value = folders } } } @@ -89,7 +88,7 @@ fun CollectionFolderLiveTv( ) { val rememberedTabIndex = remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } - val folders by viewModel.recordingFolders.observeAsState(listOf()) + val folders by viewModel.recordingFolders.collectAsState() val tvGuideStr = stringResource(R.string.tv_guide) val tvDvrStr = stringResource(R.string.tv_dvr_schedule) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt index cbea7dde..421e7ad3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMusic.kt @@ -99,7 +99,7 @@ class CollectionFolderMusicViewModel viewModelScope.launchDefault { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = itemId, includeItemTypes = listOf(BaseItemKind.AUDIO), recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index 1235fc03..ce4c7ec8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -10,13 +11,11 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderView import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel import com.github.damontecres.wholphin.ui.components.GridClickActions import com.github.damontecres.wholphin.ui.components.ViewOptionsWide -import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.toServerString @@ -50,6 +49,7 @@ fun CollectionFolderPhotoAlbum( }, ) { var showHeader by remember { mutableStateOf(true) } + val state by viewModel.state.collectAsState() CollectionFolderView( preferences = preferences, actions = @@ -63,11 +63,9 @@ fun CollectionFolderPhotoAlbum( index = index, filter = CollectionFolderFilter( - filter = viewModel.filter.value ?: GetItemsFilter(), + filter = state.filter, ), - sortAndDirection = - viewModel.sortAndDirection.value - ?: SortAndDirection.DEFAULT, + sortAndDirection = state.sortAndDirection, recursive = recursive, startSlideshow = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 543d8e2c..0f859bb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -15,8 +15,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,7 +28,6 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme @@ -43,9 +42,8 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi @@ -66,8 +64,8 @@ class DebugViewModel val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { - val itemPlaybacks = MutableLiveData>(listOf()) - val logcat = MutableLiveData>(listOf()) + val itemPlaybacks = MutableStateFlow>(emptyList()) + val logcat = MutableStateFlow>(emptyList()) val supportedModes by lazy { val displayManager = @@ -106,16 +104,14 @@ class DebugViewModel init { viewModelScope.launchIO { - serverRepository.currentUser.value?.rowId?.let { + serverRepository.currentUser?.rowId?.let { val results = itemPlaybackDao.getItems(it) - withContext(Dispatchers.Main) { - itemPlaybacks.value = results - } + itemPlaybacks.value = results } + } + viewModelScope.launchIO { val logcat = getLogCatLines() - withContext(Dispatchers.Main) { - this@DebugViewModel.logcat.value = logcat - } + this@DebugViewModel.logcat.value = logcat } } @@ -210,8 +206,8 @@ fun DebugPage( } } - val itemPlaybacks by viewModel.itemPlaybacks.observeAsState(listOf()) - val logcat by viewModel.logcat.observeAsState(listOf()) + val itemPlaybacks by viewModel.itemPlaybacks.collectAsState() + val logcat by viewModel.logcat.collectAsState() LazyColumn( state = columnState, @@ -341,17 +337,17 @@ fun DebugPage( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Current server: ${viewModel.serverRepository.currentServer.value}", + text = "Current server: ${viewModel.serverRepository.currentServer}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Current user: ${viewModel.serverRepository.currentUser.value}", + text = "Current user: ${viewModel.serverRepository.currentUser}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}", + text = "User server settings: ${viewModel.serverRepository.currentUserDto?.configuration}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index fc088cf7..e2288281 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -134,7 +133,7 @@ class HomeRowGridViewModel try { val preferences = userPreferencesService.getCurrent() val prefs = preferences.appPreferences.homePagePreferences - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUserDto?.let { userDto -> val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val result = @@ -244,7 +243,7 @@ fun HomeRowGrid( var showContextMenu by remember { mutableStateOf(null) } var overviewDialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() val contextActions = remember { @@ -381,7 +380,7 @@ fun HomeRowGrid( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt deleted file mode 100644 index 65c9a0e9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.imageApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber -import java.util.UUID - -/** - * Basic [ViewModel] for a single fetchable item from the API - */ -abstract class ItemViewModel( - val api: ApiClient, -) : ViewModel() { - val item = MutableLiveData(null) - lateinit var itemId: String - var itemUuid: UUID? = null - - suspend fun fetchItem(itemId: UUID): BaseItem = - withContext(Dispatchers.IO) { - this@ItemViewModel.itemId = itemId.toServerString() - this@ItemViewModel.itemUuid = itemId - val it = api.userLibraryApi.getItem(itemId).content - val fetchedItem = BaseItem.from(it, api) - return@withContext fetchedItem.let { - withContext(Dispatchers.Main) { - item.value = fetchedItem - } - fetchedItem - } - } - - fun imageUrl( - itemId: UUID, - type: ImageType, - ): String? = api.imageApi.getItemImageUrl(itemId, type) -} - -/** - * Extends [ItemViewModel] to include a loading state tracking when the item has been fetched or if an error occurred - */ -abstract class LoadingItemViewModel( - api: ApiClient, -) : ItemViewModel(api) { - val loading = MutableLiveData(LoadingState.Pending) - - open fun init( - itemId: UUID, - potential: BaseItem?, - ): Deferred = - viewModelScope.async( - LoadingExceptionHandler( - loading, - "Error loading item $itemId", - ) + Dispatchers.IO, - ) { - loading.setValueOnMain(LoadingState.Loading) - try { - fetchAndSetItem(itemId) - } catch (e: Exception) { - Timber.e(e, "Failed to load item $itemId") - withContext(Dispatchers.Main) { - item.value = null - loading.value = LoadingState.Error("Error loading item $itemId", e) - } - null - } - } - - open suspend fun fetchAndSetItem(itemId: UUID): BaseItem = - withContext(Dispatchers.IO) { - val item = fetchItem(itemId) - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - return@withContext item - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 913ea3e5..0efb9ae8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -33,7 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -48,7 +47,6 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.SeasonCard @@ -68,7 +66,6 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.ResStringProvider @@ -76,13 +73,16 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy @@ -91,50 +91,76 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber import java.time.LocalDate import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = PersonViewModel.Factory::class) class PersonViewModel - @Inject + @AssistedInject constructor( - api: ApiClient, + private val api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, private val seerrService: SeerrService, - ) : LoadingItemViewModel(api) { - val movies = MutableLiveData(RowLoadingState.Pending) - val series = MutableLiveData(RowLoadingState.Pending) - val episodes = MutableLiveData(RowLoadingState.Pending) - val discovered = MutableStateFlow>(listOf()) + @Assisted val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): PersonViewModel + } - fun init(itemId: UUID) { - viewModelScope.launchIO( - LoadingExceptionHandler( - loading, - "Error loading item $itemId", - ), - ) { - super.init(itemId, null).await()?.let { person -> + private val _state = MutableStateFlow(PersonState()) + val state: StateFlow = _state + + private suspend fun updatePerson(): BaseItem { + val person = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it) } + _state.update { + it.copy( + person = DataLoadingState.Success(person), + ) + } + return person + } + + init { + viewModelScope.launchIO { + try { + val person = updatePerson() val dto = person.data if ((dto.movieCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.MOVIE, movies) + fetchRow(person.id, BaseItemKind.MOVIE) { state, row -> + state.copy(movies = row) + } } else { - movies.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(movies = RowLoadingState.Success(emptyList())) } } if ((dto.seriesCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.SERIES, series) + fetchRow(person.id, BaseItemKind.SERIES) { state, row -> + state.copy(series = row) + } } else { - series.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(series = RowLoadingState.Success(emptyList())) } } if ((dto.episodeCount ?: 0) > 0) { - fetchRow(person.id, BaseItemKind.EPISODE, episodes) + fetchRow(person.id, BaseItemKind.EPISODE) { state, row -> + state.copy(episodes = row) + } } else { - episodes.setValueOnMain(RowLoadingState.Success(listOf())) + _state.update { it.copy(episodes = RowLoadingState.Success(emptyList())) } } viewModelScope.launchIO { val results = seerrService.similar(person).orEmpty() - discovered.update { results } + _state.update { + it.copy( + discovered = results, + ) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching person %s", itemId) + _state.update { it.copy(person = DataLoadingState.Error(ex)) } } } } @@ -142,10 +168,12 @@ class PersonViewModel private fun fetchRow( itemId: UUID, type: BaseItemKind, - target: MutableLiveData, + update: (PersonState, RowLoadingState) -> PersonState, ) { viewModelScope.launchIO { - target.setValueOnMain(RowLoadingState.Loading) + _state.update { + update.invoke(it, RowLoadingState.Loading) + } try { val request = GetItemsRequest( @@ -165,96 +193,105 @@ class PersonViewModel pageSize = 15, useSeriesForPrimary = false, ).init() - target.setValueOnMain(RowLoadingState.Success(pager)) + _state.update { + update.invoke(it, RowLoadingState.Success(pager)) + } } catch (ex: Exception) { Timber.e(ex, "Error fetching $type for $itemId") - target.setValueOnMain(RowLoadingState.Error(ex)) + _state.update { + update.invoke(it, RowLoadingState.Error(ex)) + } } } } fun setFavorite(favorite: Boolean) { viewModelScope.launchIO { - itemUuid?.let { - favoriteWatchManager.setFavorite(it, favorite) - fetchAndSetItem(it) - } + favoriteWatchManager.setFavorite(itemId, favorite) + updatePerson() } } } +data class PersonState( + val person: DataLoadingState = DataLoadingState.Pending, + val movies: RowLoadingState = RowLoadingState.Pending, + val series: RowLoadingState = RowLoadingState.Pending, + val episodes: RowLoadingState = RowLoadingState.Pending, + val discovered: List = emptyList(), +) + @Composable fun PersonPage( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: PersonViewModel = hiltViewModel(), + viewModel: PersonViewModel = + hiltViewModel( + creationCallback = { it.create(destination.itemId) }, + ), ) { - OneTimeLaunchedEffect { - viewModel.init(destination.itemId) - } - val person by viewModel.item.observeAsState() - val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) - val series by viewModel.series.observeAsState(RowLoadingState.Pending) - val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) - val discovered by viewModel.discovered.collectAsState() - - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + val state by viewModel.state.collectAsState() + when (val st = state.person) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - person?.let { person -> - var showOverviewDialog by remember { mutableStateOf(false) } - val name = person.name ?: person.id.toString() - val imageUrlService = LocalImageUrlService.current - val imageUrl = remember { imageUrlService.getItemImageUrl(itemId = person.id, imageType = ImageType.PRIMARY) } - PersonPageContent( - preferences = preferences, - name = name, - overview = person.data.overview, - imageUrl = imageUrl, - birthdate = person.data.premiereDate?.toLocalDate(), - deathdate = person.data.endDate?.toLocalDate(), - birthPlace = person.data.productionLocations?.firstOrNull(), - favorite = person.favorite, - movies = movies, - series = series, - episodes = episodes, - onClickItem = { index, item -> - viewModel.navigationManager.navigateTo(item.destination()) - }, - overviewOnClick = { showOverviewDialog = true }, - favoriteOnClick = { - viewModel.setFavorite(!person.favorite) - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigationManager.navigateTo(item.destination) - }, - modifier = modifier, - ) - AnimatedVisibility(showOverviewDialog) { - ItemDetailsDialog( - info = - ItemDetailsDialogInfo( - title = name, - overview = person.data.overview, - genres = listOf(), - files = listOf(), - ), - showFilePath = false, - onDismissRequest = { showOverviewDialog = false }, + is DataLoadingState.Success -> { + val person = st.data + var showOverviewDialog by remember { mutableStateOf(false) } + val name = remember(person) { person.name ?: person.id.toString() } + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember { + imageUrlService.getItemImageUrl( + itemId = person.id, + imageType = ImageType.PRIMARY, ) } + PersonPageContent( + preferences = preferences, + name = name, + overview = person.data.overview, + imageUrl = imageUrl, + birthdate = person.data.premiereDate?.toLocalDate(), + deathdate = person.data.endDate?.toLocalDate(), + birthPlace = person.data.productionLocations?.firstOrNull(), + favorite = person.favorite, + movies = state.movies, + series = state.series, + episodes = state.episodes, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + overviewOnClick = { showOverviewDialog = true }, + favoriteOnClick = { + viewModel.setFavorite(!person.favorite) + }, + discovered = state.discovered, + onClickDiscover = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, + modifier = modifier, + ) + AnimatedVisibility(showOverviewDialog) { + ItemDetailsDialog( + info = + ItemDetailsDialogInfo( + title = name, + overview = person.data.overview, + genres = listOf(), + files = listOf(), + ), + showFilePath = false, + onDismissRequest = { showOverviewDialog = false }, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index c69fc0e6..7a78701a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -168,7 +167,7 @@ class PlaylistViewModel .let { BaseItem(it, false) } state.update { it.copy(playlist = playlist) } val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> libraryDisplayInfoDao.getItem(user, itemId) } val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() @@ -198,7 +197,7 @@ class PlaylistViewModel ) } - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> viewModelScope.launchIO { val libraryDisplayInfo = libraryDisplayInfoDao.getItem(user, itemId)?.copy( @@ -304,7 +303,7 @@ class PlaylistViewModel suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = FilterUtils.getFilterOptionValues( api, - serverRepository.currentUser.value?.id, + serverRepository.currentUser?.id, itemId, filterOption, ) @@ -374,7 +373,7 @@ fun PlaylistDetails( var showContextMenu by remember { mutableStateOf(null) } var showConfirmTypeDialog by remember { mutableStateOf?>(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val addPlaylistState by addToPlaylistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val addPlaylistState by addToPlaylistViewModel.playlistState.collectAsState() fun play( index: Int, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt index 4da00ef7..3077727c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -52,7 +51,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -79,7 +77,7 @@ fun CollectionDetails( // Dialogs var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showViewOptionsDialog by remember { mutableStateOf(false) } var overviewDialog by remember { mutableStateOf(null) } @@ -247,7 +245,7 @@ fun CollectionDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + viewModel.serverRepository.currentUserDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt index c79eada4..f5cbd741 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.detail.collection import android.content.Context import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository @@ -99,16 +98,14 @@ class CollectionViewModel } private val viewOptionsFlow = - serverRepository.currentUser - .asFlow() + serverRepository.currentUserFlow .filterNotNull() .flatMapLatest { keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) private val libraryDisplayInfoFlow = - serverRepository.currentUser - .asFlow() + serverRepository.currentUserFlow .filterNotNull() .flatMapLatest { libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) @@ -282,7 +279,7 @@ class CollectionViewModel } val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, parentId = itemId, includeItemTypes = includeItemTypes, excludeItemTypes = excludeItemTypes, @@ -298,7 +295,7 @@ class CollectionViewModel fun changeSort(sortAndDirection: SortAndDirection) { viewModelScope.launchIO { - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val state = _state.value if (user != null) { libraryDisplayInfoDao.saveItem( @@ -317,7 +314,7 @@ class CollectionViewModel fun changeFilter(filter: GetItemsFilter) { viewModelScope.launchIO { - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val state = _state.value if (user != null) { libraryDisplayInfoDao.saveItem( @@ -344,7 +341,7 @@ class CollectionViewModel backdropService.clearBackdrop() } } - serverRepository.currentUser.value?.id?.let { userId -> + serverRepository.currentUser?.id?.let { userId -> keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) } } @@ -353,7 +350,7 @@ class CollectionViewModel suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List = FilterUtils.getFilterOptionValues( api, - serverRepository.currentUser.value?.id, + serverRepository.currentUser?.id, itemId, filterOption, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 5a520c48..d6bcd152 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -64,8 +63,8 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus +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.serializer.toUUIDOrNull @@ -86,16 +85,9 @@ fun DiscoverMovieDetails( viewModel.init() onPauseOrDispose { } } - val item by viewModel.movie.observeAsState() - val rating by viewModel.rating.observeAsState(null) - val people by viewModel.people.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.recommended.observeAsState(listOf()) - val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val state by viewModel.state.collectAsState() val userConfig by viewModel.userConfig.collectAsState(null) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) - val canCancel by viewModel.canCancelRequest.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -103,98 +95,97 @@ fun DiscoverMovieDetails( val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.movie) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { movie -> - DiscoverMovieDetailsContent( - preferences = preferences, - movie = movie, - userConfig = userConfig, - rating = rating, - canCancel = canCancel, - people = people, - trailers = trailers, - similar = similar, - recommended = recommended, - requestOnClick = { - movie.id?.let { id -> - if (request4kEnabled) { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = - listOf( - DialogItem( - text = requestStr, - onClick = { viewModel.request(id, false) }, - ), - DialogItem( - text = request4kStr, - onClick = { viewModel.request(id, true) }, - ), + is DataLoadingState.Success -> { + val movie = st.data + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + userConfig = userConfig, + rating = state.rating, + canCancel = state.canCancelRequest, + people = state.people, + trailers = state.trailers, + similar = state.similar, + recommended = state.recommended, + requestOnClick = { + movie.id?.let { id -> + if (request4kEnabled) { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = + listOf( + DialogItem( + text = requestStr, + onClick = { viewModel.request(id, false) }, ), - ) - } else { - viewModel.request(id, false) - } + DialogItem( + text = request4kStr, + onClick = { viewModel.request(id, true) }, + ), + ), + ) + } else { + viewModel.request(id, false) } - }, - cancelOnClick = { - movie.id?.let { viewModel.cancelRequest(it) } - }, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = movie.title ?: resources.getString(R.string.unknown), - overview = movie.overview, - genres = movie.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - goToOnClick = { - movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } - }, - moreOnClick = { - moreDialog = - DialogParams( - fromLongClick = false, - title = movie.title + " (${movie.releaseDate ?: ""})", - items = listOf(), - ) - }, - onLongClickPerson = { index, person -> }, - onLongClickSimilar = { index, similar -> - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - modifier = modifier, - ) - } + } + }, + cancelOnClick = { + movie.id?.let { viewModel.cancelRequest(it) } + }, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: resources.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + goToOnClick = { + movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = listOf(), + ) + }, + onLongClickPerson = { index, person -> }, + onLongClickSimilar = { index, similar -> + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + modifier = modifier, + ) } } overviewDialog?.let { info -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 3edd7dd6..f80457fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.model.MediaRequest @@ -23,9 +22,8 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -36,13 +34,13 @@ 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.firstOrNull 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 timber.log.Timber +import kotlin.coroutines.cancellation.CancellationException @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) class DiscoverMovieViewModel @@ -62,15 +60,8 @@ class DiscoverMovieViewModel fun create(item: DiscoverItem): DiscoverMovieViewModel } - val loading = MutableLiveData(LoadingState.Pending) - val movie = MutableLiveData(null) - val rating = MutableLiveData(null) - - val trailers = MutableLiveData>(listOf()) - val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>() - val recommended = MutableLiveData>() - val canCancelRequest = MutableStateFlow(false) + private val _state = MutableStateFlow(DiscoverMovieState()) + val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } val request4kEnabled = @@ -80,85 +71,92 @@ class DiscoverMovieViewModel init() } - private fun fetchAndSetItem(): Deferred = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) - this@DiscoverMovieViewModel.movie.setValueOnMain(movie) - movie + private fun fetchAndSetItem(): Deferred = + viewModelScope.async(Dispatchers.IO) { + try { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + _state.update { it.copy(movie = DataLoadingState.Success(movie)) } + movie + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating movie details") + null + } } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { + viewModelScope.launchIO { Timber.v("Init for movie %s", item.id) - val movie = fetchAndSetItem().await() - val discoveredItem = seerrService.createDiscoverItem(movie) - backdropService.submit(discoveredItem) + try { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + _state.update { it.copy(movie = DataLoadingState.Success(movie)) } + val discoveredItem = seerrService.createDiscoverItem(movie) + backdropService.submit(discoveredItem) - updateCanCancel() + updateCanCancel() - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - viewModelScope.launchIO { - val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) - rating.setValueOnMain(DiscoverRating(result)) - } - if (!similar.isInitialized) { viewModelScope.launchIO { val result = - seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - similar.setValueOnMain(result) + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) + _state.update { it.copy(rating = DiscoverRating(result)) } } - viewModelScope.launchIO { - val result = - seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - recommended.setValueOnMain(result) + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(similar = result) } + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(recommended = result) } + } } - } - val people = - movie.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + + val people = movie.credits - ?.crew + ?.cast ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - this@DiscoverMovieViewModel.people.setValueOnMain(people) - val trailers = - movie.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) + .orEmpty() + + movie.credits + ?.crew + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(people = people) } + val trailers = + movie.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + _state.update { it.copy(trailers = trailers) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating movie details") + _state.update { it.copy(movie = DataLoadingState.Error(ex)) } + } } private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() - val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests) - canCancelRequest.update { canCancel } + val canCancel = + canUserCancelRequest( + user, + state.value.movie.successValue + ?.mediaInfo + ?.requests, + ) + _state.update { it.copy(canCancelRequest = canCancel) } } fun navigateTo(destination: Destination) { @@ -185,7 +183,7 @@ class DiscoverMovieViewModel fun cancelRequest(id: Int) { viewModelScope.launchIO { - movie.value?.mediaInfo?.requests?.firstOrNull()?.let { + state.value.movie.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) fetchAndSetItem().await() @@ -204,3 +202,14 @@ fun canUserCancelRequest( user.hasPermission(SeerrPermission.REQUEST) && requests?.any { it.requestedBy?.id == user?.id } == true ) + +data class DiscoverMovieState( + val movie: DataLoadingState = DataLoadingState.Pending, + val rating: DiscoverRating? = null, + val seasons: List = emptyList(), + val trailers: List = emptyList(), + val people: List = emptyList(), + val similar: List = emptyList(), + val recommended: List = emptyList(), + val canCancelRequest: Boolean = false, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 609bd93a..a493cab3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -66,8 +65,9 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.successValue import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -85,17 +85,8 @@ fun DiscoverSeriesDetails( ) { val context = LocalContext.current val resources = LocalResources.current - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - - val item by viewModel.tvSeries.observeAsState() - val seasons by viewModel.seasons.observeAsState(listOf()) - val people by viewModel.people.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.recommended.observeAsState(listOf()) - val userConfig by viewModel.userConfig.collectAsState(null) + val state by viewModel.state.collectAsState() val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) - val canCancel by viewModel.canCancelRequest.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var seasonDialog by remember { mutableStateOf(null) } @@ -105,74 +96,73 @@ fun DiscoverSeriesDetails( val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.tvSeries) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { item -> - val rating by viewModel.rating.observeAsState(null) - DiscoverSeriesDetailsContent( - preferences = preferences, - series = item, - userConfig = userConfig, - rating = rating, - canCancel = canCancel, - seasons = seasons, - people = people, - similar = similar, - recommended = recommended, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(Destination.DiscoveredItem(item)) - }, - onClickPerson = { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - }, - goToOnClick = { - item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { - viewModel.navigateTo( - Destination.MediaItem( - itemId = it, - type = BaseItemKind.MOVIE, - ), - ) - } - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo( - title = item.name ?: resources.getString(R.string.unknown), - overview = item.overview, - genres = item.genres?.mapNotNull { it.name }.orEmpty(), - files = listOf(), - ) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - trailers = trailers, - requestOnClick = { - item.id?.let { id -> - showRequestSeasonDialog = true - } - }, - cancelOnClick = { - item.id?.let { viewModel.cancelRequest(it) } - }, - moreOnClick = { - }, - onLongClickPerson = { _, _ -> }, - onLongClickSimilar = { _, _ -> }, - ) - } + is DataLoadingState.Success -> { + val item = st.data + val userConfig by viewModel.userConfig.collectAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + userConfig = userConfig, + rating = state.rating, + canCancel = state.canCancelRequest, + seasons = state.seasons, + people = state.people, + similar = state.similar, + recommended = state.recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + }, + goToOnClick = { + item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: resources.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = state.trailers, + requestOnClick = { + item.id?.let { id -> + showRequestSeasonDialog = true + } + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) } } overviewDialog?.let { info -> @@ -203,11 +193,13 @@ fun DiscoverSeriesDetails( } if (showRequestSeasonDialog) { RequestSeasonsDialog( - title = item?.name ?: "", - seasons = seasons, + title = state.tvSeries.successValue?.name ?: "", + seasons = state.seasons, request4kEnabled = request4kEnabled, onSubmit = { seasons, is4k -> - item?.id?.let { viewModel.request(it, seasons, is4k) } + state.tvSeries.successValue + ?.id + ?.let { viewModel.request(it, seasons, is4k) } showRequestSeasonDialog = false }, onDismissRequest = { showRequestSeasonDialog = false }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index 54b3d98f..fc40b0c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo @@ -21,25 +20,24 @@ import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.successValue 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.CancellationException 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.first import kotlinx.coroutines.flow.firstOrNull 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 timber.log.Timber @@ -61,16 +59,8 @@ class DiscoverSeriesViewModel fun create(item: DiscoverItem): DiscoverSeriesViewModel } - val loading = MutableLiveData(LoadingState.Pending) - val tvSeries = MutableLiveData(null) - val rating = MutableLiveData(null) - - val seasons = MutableLiveData>(listOf()) - val trailers = MutableLiveData>(listOf()) - val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>() - val recommended = MutableLiveData>() - val canCancelRequest = MutableStateFlow(false) + private val _state = MutableStateFlow(DiscoverSeriesState()) + val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } @@ -79,128 +69,132 @@ class DiscoverSeriesViewModel init() } - private fun fetchAndSetItem(): Deferred = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) - this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) - tv + private fun fetchAndSetItem(): Deferred = + viewModelScope.async(Dispatchers.IO) { + try { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } + tv + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error updating tv details") + null + } } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { + viewModelScope.launchIO { Timber.v("Init for tv %s", item.id) - val tv = fetchAndSetItem().await() - val discoveredItem = seerrService.createDiscoverItem(tv) - backdropService.submit(discoveredItem) + try { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + _state.update { it.copy(tvSeries = DataLoadingState.Success(tv)) } + val discoveredItem = seerrService.createDiscoverItem(tv) + backdropService.submit(discoveredItem) - updateSeasonStatus() - updateCanCancel() + updateSeasonStatus(tv) + updateCanCancel() - withContext(Dispatchers.Main) { - loading.value = LoadingState.Success - } - viewModelScope.launchIO { - val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) - rating.setValueOnMain(DiscoverRating(result)) - } - if (!similar.isInitialized) { viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdSimilarGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - similar.setValueOnMain(result) + val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) + _state.update { it.copy(rating = DiscoverRating(result)) } } - viewModelScope.launchIO { - val result = - seerrService.api.tvApi - .tvTvIdRecommendationsGet(tvId = item.id, page = 1) - .results - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - recommended.setValueOnMain(result) + if (state.value.similar.isEmpty()) { + viewModelScope.launchIO { + val result = + seerrService.api.tvApi + .tvTvIdSimilarGet(tvId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(similar = result) } + } + viewModelScope.launchIO { + val result = + seerrService.api.tvApi + .tvTvIdRecommendationsGet(tvId = item.id, page = 1) + .results + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(recommended = result) } + } } - } - val people = - tv.credits - ?.cast - ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() + + val people = tv.credits - ?.crew + ?.cast ?.map { seerrService.createDiscoverItem(it) } - .orEmpty() - this@DiscoverSeriesViewModel.people.setValueOnMain(people) + .orEmpty() + + tv.credits + ?.crew + ?.map { seerrService.createDiscoverItem(it) } + .orEmpty() + _state.update { it.copy(people = people) } - val trailers = - tv.relatedVideos - ?.filter { it.type == RelatedVideo.Type.TRAILER } - ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } - ?.map { - RemoteTrailer(it.name!!, it.url!!, it.site) - }.orEmpty() - this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers) + val trailers = + tv.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + _state.update { it.copy(trailers = trailers) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting tv details") + _state.update { it.copy(tvSeries = DataLoadingState.Error(ex)) } + } } fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } - private suspend fun updateSeasonStatus() { - tvSeries.value?.let { tv -> - val seasonStatus = mutableMapOf() - tv.seasons?.forEach { - it.seasonNumber?.let { - seasonStatus[it] = SeerrAvailability.UNKNOWN - } + private fun updateSeasonStatus(tv: TvDetails) { + val seasonStatus = mutableMapOf() + tv.seasons?.forEach { + it.seasonNumber?.let { + seasonStatus[it] = SeerrAvailability.UNKNOWN } - val tvStatus = - SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN - tv.mediaInfo - ?.requests - ?.forEach { - it.seasons?.mapNotNull { season -> - season.seasonNumber?.let { - val current = seasonStatus[season.seasonNumber] - val new = - SeerrAvailability - .from(season.status) - ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus - if (current == null || new.status > current.status) { - seasonStatus[season.seasonNumber] = new - } + } + val tvStatus = + SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo + ?.requests + ?.forEach { + it.seasons?.mapNotNull { season -> + season.seasonNumber?.let { + val current = seasonStatus[season.seasonNumber] + val new = + SeerrAvailability + .from(season.status) + ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + if (current == null || new.status > current.status) { + seasonStatus[season.seasonNumber] = new } } } - Timber.v("seasonStatus=%s", seasonStatus) - val requestSeasons = - seasonStatus.mapNotNull { (seasonNumber, availability) -> - tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { - RequestSeason(it, availability) - } + } + Timber.v("seasonStatus=%s", seasonStatus) + val requestSeasons = + seasonStatus.mapNotNull { (seasonNumber, availability) -> + tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { + RequestSeason(it, availability) } - seasons.setValueOnMain(requestSeasons) - } + } + _state.update { it.copy(seasons = requestSeasons) } } private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() - val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) - canCancelRequest.update { canCancel } + val canCancel = + canUserCancelRequest( + user, + state.value.tvSeries.successValue + ?.mediaInfo + ?.requests, + ) + _state.update { it.copy(canCancelRequest = canCancel) } } fun request( @@ -209,7 +203,7 @@ class DiscoverSeriesViewModel is4k: Boolean, ) { viewModelScope.launchIO { - tvSeries.value?.let { tv -> + state.value.tvSeries.successValue?.let { tv -> val currentRequest = tv.mediaInfo?.requests?.firstOrNull { it.requestedBy?.id == @@ -238,21 +232,35 @@ class DiscoverSeriesViewModel ) } - fetchAndSetItem().await() - updateSeasonStatus() - updateCanCancel() + fetchAndSetItem().await()?.let { + updateSeasonStatus(it) + updateCanCancel() + } } } } fun cancelRequest(id: Int) { viewModelScope.launchIO { - tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + state.value.tvSeries.successValue?.mediaInfo?.requests?.firstOrNull()?.let { // TODO handle multiple requests? Or just delete self's request? seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) - fetchAndSetItem().await() - updateCanCancel() + fetchAndSetItem().await()?.let { + updateSeasonStatus(it) + updateCanCancel() + } } } } } + +data class DiscoverSeriesState( + val tvSeries: DataLoadingState = DataLoadingState.Pending, + val rating: DiscoverRating? = null, + val seasons: List = emptyList(), + val trailers: List = emptyList(), + val people: List = emptyList(), + val similar: List = emptyList(), + val recommended: List = emptyList(), + val canCancelRequest: Boolean = false, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 803e18ce..fa45058d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -43,11 +42,10 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState 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.MediaType import org.jellyfin.sdk.model.extensions.ticks @@ -66,27 +64,20 @@ fun EpisodeDetails( ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current LifecycleResumeEffect(Unit) { viewModel.init() onPauseOrDispose { } } - val item by viewModel.item.observeAsState() - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val state by viewModel.state.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() val canDelete by viewModel.canDelete.collectAsState() - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference val contextActions = remember { @@ -119,75 +110,74 @@ fun EpisodeDetails( ) } - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.episode) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { ep -> - LifecycleResumeEffect(ep) { - ep.data.seriesId?.let { seriesId -> - viewModel.maybePlayThemeSong(seriesId) - } - onPauseOrDispose { - viewModel.release() - } + is DataLoadingState.Success -> { + val ep = st.data + LifecycleResumeEffect(ep) { + ep.data.seriesId?.let { seriesId -> + viewModel.maybePlayThemeSong(seriesId) + } + onPauseOrDispose { + viewModel.release() } - EpisodeDetailsContent( - preferences = preferences, - ep = ep, - chosenStreams = chosenStreams, - playOnClick = { - viewModel.navigateTo( - Destination.Playback( - ep.id, - it.inWholeMilliseconds, - ), - ) - }, - overviewOnClick = { - overviewDialog = - ItemDetailsDialogInfo(ep) - }, - moreOnClick = { - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = canDelete, - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - }, - watchOnClick = { - viewModel.setWatched(ep.id, !ep.played) - }, - favoriteOnClick = { - viewModel.setFavorite(ep.id, !ep.favorite) - }, - canDelete = canDelete, - onConfirmDelete = { viewModel.deleteItem(ep) }, - modifier = modifier, - ) } + EpisodeDetailsContent( + preferences = preferences, + ep = ep, + chosenStreams = state.chosenStreams, + playOnClick = { + viewModel.navigateTo( + Destination.Playback( + ep.id, + it.inWholeMilliseconds, + ), + ) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo(ep) + }, + moreOnClick = { + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = false, + item = ep, + chosenStreams = state.chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = canDelete, + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + watchOnClick = { + viewModel.setWatched(ep.id, !ep.played) + }, + favoriteOnClick = { + viewModel.setFavorite(ep.id, !ep.favorite) + }, + canDelete = canDelete, + onConfirmDelete = { viewModel.deleteItem(ep) }, + modifier = modifier, + ) } } overviewDialog?.let { info -> ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ec09ded1..1c6249da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -1,9 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.episode 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.ItemPlaybackRepository @@ -22,26 +20,26 @@ import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState 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.CancellationException 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.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.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType +import timber.log.Timber import java.util.UUID @HiltViewModel(assistedFactory = EpisodeViewModel.Factory::class) @@ -67,53 +65,61 @@ class EpisodeViewModel fun create(itemId: UUID): EpisodeViewModel } - val loading = MutableLiveData(LoadingState.Pending) - val item = MutableLiveData(null) - val chosenStreams = MutableLiveData(null) + private val _state = MutableStateFlow(EpisodeState()) + val state: StateFlow = _state val canDelete = MutableStateFlow(false) init { init() viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(item.asFlow()) { canDelete -> + mediaManagementService.collectCanDelete( + state.map { (it.episode as? DataLoadingState.Success)?.data }, + ) { canDelete -> this@EpisodeViewModel.canDelete.update { canDelete } } } } - private fun fetchAndSetItem(): Deferred = - viewModelScope.async( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from(it, api) - } - this@EpisodeViewModel.item.setValueOnMain(item) - item + private fun fetchAndSetItem() { + viewModelScope.launchIO { + try { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from(it, api) + } + _state.update { it.copy(episode = DataLoadingState.Success(item)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting episode %s", itemId) + showToast(context, "Error updating episode") + } } + } fun init(): Job = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error fetching movie", - ), - ) { - val prefs = userPreferencesService.getCurrent() - val item = fetchAndSetItem().await() - val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) - withContext(Dispatchers.Main) { - this@EpisodeViewModel.item.value = item - chosenStreams.value = result - loading.value = LoadingState.Success + viewModelScope.launchIO { + try { + val prefs = userPreferencesService.getCurrent() + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem(it) + } + val chosenStreams = + itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) + _state.update { + it.copy( + episode = DataLoadingState.Success(item), + chosenStreams = chosenStreams, + ) + } backdropService.submit(item) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error getting episode %s", itemId) + _state.update { it.copy(episode = DataLoadingState.Error(ex)) } } } @@ -145,9 +151,7 @@ class EpisodeViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -171,9 +175,7 @@ class EpisodeViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -195,14 +197,16 @@ class EpisodeViewModel fun clearChosenStreams(chosenStreams: ChosenStreams?) { viewModelScope.launchIO { itemPlaybackRepository.deleteChosenStreams(chosenStreams) - item.value?.let { item -> - val result = - itemPlaybackRepository.getSelectedTracks( - itemId, - item, - userPreferencesService.getCurrent(), - ) - this@EpisodeViewModel.chosenStreams.setValueOnMain(result) + state.value.episode.let { item -> + if (item is DataLoadingState.Success) { + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item.data, + userPreferencesService.getCurrent(), + ) + _state.update { it.copy(chosenStreams = result) } + } } } } @@ -213,3 +217,8 @@ class EpisodeViewModel } } } + +data class EpisodeState( + val episode: DataLoadingState = DataLoadingState.Pending, + val chosenStreams: ChosenStreams? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index d1d4fac6..d413f190 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -10,8 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem @@ -44,13 +43,14 @@ import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi +import timber.log.Timber import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneId @@ -63,36 +63,51 @@ class DvrScheduleViewModel private val api: ApiClient, val navigationManager: NavigationManager, ) : ViewModel() { - val loading = MutableLiveData(LoadingState.Loading) - val active = MutableLiveData>() - val scheduled = MutableLiveData>>() + private val _state = MutableStateFlow(DvrScheduleState()) + val state: StateFlow = _state + + init { + init() + } fun init() { -// loading.value = LoadingState.Loading - viewModelScope.launchIO(LoadingExceptionHandler(loading, "Error fetching DVR Schedule")) { - val active = - api.liveTvApi - .getRecordings( - isInProgress = true, - fields = SlimItemFields, - limit = 100, - ).content.items - .map { BaseItem.from(it, api, true) } - val scheduled = - api.liveTvApi - .getTimers( - isActive = false, - isScheduled = true, - ).content.items - .map { BaseItem.from(it.programInfo!!, api, true) } // TODO this probably breaks for time based recordings - .groupBy { - it.data.startDate!!.toLocalDate() - } + viewModelScope.launchIO { + try { + val active = + api.liveTvApi + .getRecordings( + isInProgress = true, + fields = SlimItemFields, + limit = 100, + ).content.items + .map { BaseItem.from(it, api, true) } + val scheduled = + api.liveTvApi + .getTimers( + isActive = false, + isScheduled = true, + ).content.items + .map { + BaseItem.from( + it.programInfo!!, + api, + true, + ) + } // TODO this probably breaks for time based recordings + .groupBy { + it.data.startDate!!.toLocalDate() + } - withContext(Dispatchers.Main) { - this@DvrScheduleViewModel.active.value = active - this@DvrScheduleViewModel.scheduled.value = scheduled - loading.value = LoadingState.Success + _state.update { + it.copy( + loading = LoadingState.Success, + active = active, + scheduled = scheduled, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching DVR schedule") + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } @@ -112,6 +127,12 @@ class DvrScheduleViewModel } } +data class DvrScheduleState( + val loading: LoadingState = LoadingState.Loading, + val active: List = emptyList(), + val scheduled: Map> = emptyMap(), +) + @Composable fun DvrSchedule( requestFocusAfterLoading: Boolean, @@ -122,12 +143,10 @@ fun DvrSchedule( LaunchedEffect(Unit) { viewModel.init() } - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val active by viewModel.active.observeAsState(listOf()) - val recordings by viewModel.scheduled.observeAsState(mapOf()) - when (val state = loading) { + val state by viewModel.state.collectAsState() + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -141,7 +160,7 @@ fun DvrSchedule( val focusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { LaunchedEffect(Unit) { - if (active.isNotEmpty() || recordings.isNotEmpty()) { + if (state.active.isNotEmpty() || state.scheduled.isNotEmpty()) { focusRequester.tryRequestFocus() } else { focusRequesterOnEmpty.tryRequestFocus() @@ -149,8 +168,8 @@ fun DvrSchedule( } } DvrScheduleContent( - activeRecordings = active, - scheduledRecordings = recordings, + activeRecordings = state.active, + scheduledRecordings = state.scheduled, onClickItem = { showDialog = it }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 173c6cf2..01279c97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -121,7 +121,7 @@ class LiveTvViewModel val channelData by api.liveTvApi.getLiveTvChannels( GetLiveTvChannelsRequest( startIndex = 0, - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, enableFavoriteSorting = favoriteChannelsAtBeginning, sortBy = if (sortByRecentlyWatched) { @@ -229,7 +229,7 @@ class LiveTvViewModel minEndDate = minEndDate, channelIds = channelsToFetch.map { it.id }, sortBy = listOf(ItemSortBy.START_DATE), - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, fields = listOf(ItemFields.OVERVIEW), ) val fetchedPrograms = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 82b0f0ce..785ca74e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -58,7 +57,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty @@ -97,14 +95,10 @@ fun MovieDetails( var overviewDialog by remember { mutableStateOf(null) } var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference val contextActions = remember { @@ -258,7 +252,7 @@ fun MovieDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 1b5ab26e..9df28425 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -166,7 +166,7 @@ class MovieViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, fields = SlimItemFields, limit = 25, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt index 79633fc1..293807fb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/AlbumDetailsPage.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -76,7 +75,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -188,7 +186,7 @@ class AlbumViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, excludeArtistIds = album.data.albumArtists?.map { it.id }, fields = SlimItemFields, @@ -202,7 +200,7 @@ class AlbumViewModel viewModelScope.launchIO { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, albumIds = listOf(itemId), parentId = null, fields = DefaultItemFields, @@ -328,7 +326,7 @@ fun AlbumDetailsPage( remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusManager = LocalFocusManager.current var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showContextMenu by remember { mutableStateOf(null) } val moreDialogActions = remember { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt index 1f693322..e87419aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/ArtistDetailsPage.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -71,7 +70,6 @@ import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -210,7 +208,7 @@ class ArtistViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = itemId, excludeArtistIds = listOf(itemId), fields = SlimItemFields, @@ -224,7 +222,7 @@ class ArtistViewModel viewModelScope.launchIO { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, artistIds = listOf(itemId), parentId = null, fields = DefaultItemFields, @@ -319,7 +317,7 @@ fun ArtistDetailsPage( remember { List(SIMILAR_ROW + 1) { FocusRequester() } } var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var showContextMenu by remember { mutableStateOf(null) } val moreDialogActions = remember { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt index baa98ef1..245aad05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt @@ -48,7 +48,7 @@ class SearchForViewModel try { val request = GetItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, searchTerm = query, includeItemTypes = listOf(searchType), recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index bc735720..7c437f21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -34,7 +33,6 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -79,7 +77,6 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty @@ -89,7 +86,6 @@ import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType 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.MediaType @@ -111,20 +107,9 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val focusManager = LocalFocusManager.current - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val item by viewModel.item.observeAsState() - val canDelete by viewModel.canDeleteSeries.collectAsState() - val seasons by viewModel.seasons.observeAsState(listOf()) - val trailers by viewModel.trailers.observeAsState(listOf()) - val extras by viewModel.extras.observeAsState(listOf()) - val people by viewModel.people.observeAsState(listOf()) - val similar by viewModel.similar.observeAsState(listOf()) - val discovered by viewModel.discovered.collectAsState() - val discoverSeries by viewModel.discoverSeries.collectAsState() - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val state by viewModel.state.collectAsState() + val playlistState by playlistViewModel.playlistState.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showContextMenu by remember { mutableStateOf(null) } @@ -180,118 +165,116 @@ fun SeriesDetails( } } - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - item?.let { item -> - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() + is DataLoadingState.Success -> { + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() - onPauseOrDispose {} - } - - val played = item.data.userData?.played ?: false - SeriesDetailsContent( - preferences = preferences, - series = item, - seasons = seasons, - trailers = trailers, - extras = extras, - people = people, - similar = similar, - played = played, - favorite = item.data.userData?.isFavorite ?: false, - canDelete = canDelete, - modifier = modifier, - onClickItem = { index, item -> - viewModel.navigateTo(item.destination()) - }, - onClickPerson = { + onPauseOrDispose {} + } + val series = st.data + val played = series.data.userData?.played ?: false + SeriesDetailsContent( + preferences = preferences, + series = series, + seasons = state.seasons, + trailers = state.trailers, + extras = state.extras, + people = state.people, + similar = state.similar, + played = played, + favorite = series.data.userData?.isFavorite ?: false, + canDelete = state.canDeleteSeries, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + onLongClickItem = { index, season -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = season, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(season, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + overviewOnClick = { + overviewDialog = ItemDetailsDialogInfo(series) + }, + playOnClick = { shuffle -> + if (shuffle) { viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, + Destination.PlaybackList( + itemId = series.id, + shuffle = true, ), ) + } else { + viewModel.playNextUp() + } + }, + watchOnClick = { showWatchConfirmation = true }, + favoriteOnClick = { + val favorite = series.data.userData?.isFavorite ?: false + viewModel.setFavorite(series.id, !favorite, null) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { _, extra -> + viewModel.navigateTo(extra.destination) + }, + discoverSeries = state.discoverSeries, + onClickDiscoverSeries = { + state.discoverSeries?.let { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + } + }, + discovered = state.discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, + onShowContextMenu = { + showContextMenu = it + }, + actions = contextActions, + ) + if (showWatchConfirmation) { + ConfirmDialog( + title = series.name ?: "", + body = + stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), + onCancel = { + showWatchConfirmation = false }, - onLongClickItem = { index, season -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = true, - item = season, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(season, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) + onConfirm = { + viewModel.setWatchedSeries(!played) + showWatchConfirmation = false }, - overviewOnClick = { - overviewDialog = ItemDetailsDialogInfo(item) - }, - playOnClick = { shuffle -> - if (shuffle) { - viewModel.navigateTo( - Destination.PlaybackList( - itemId = item.id, - shuffle = true, - ), - ) - } else { - viewModel.playNextUp() - } - }, - watchOnClick = { showWatchConfirmation = true }, - favoriteOnClick = { - val favorite = item.data.userData?.isFavorite ?: false - viewModel.setFavorite(item.id, !favorite, null) - }, - trailerOnClick = { - TrailerService.onClick(context, it, viewModel::navigateTo) - }, - onClickExtra = { _, extra -> - viewModel.navigateTo(extra.destination) - }, - discoverSeries = discoverSeries, - onClickDiscoverSeries = { - discoverSeries?.let { - viewModel.navigateTo(Destination.DiscoveredItem(it)) - } - }, - discovered = discovered, - onClickDiscover = { index, item -> - viewModel.navigateTo(item.destination) - }, - onShowContextMenu = { - showContextMenu = it - }, - actions = contextActions, ) - if (showWatchConfirmation) { - ConfirmDialog( - title = item.name ?: "", - body = - stringResource(if (played) R.string.mark_entire_series_as_unplayed else R.string.mark_entire_series_as_played), - onCancel = { - showWatchConfirmation = false - }, - onConfirm = { - viewModel.setWatchedSeries(!played) - showWatchConfirmation = false - }, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 6d99e156..7532be7a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -6,21 +6,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -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 -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.map import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.ContextMenu @@ -32,10 +29,9 @@ import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.DataLoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -82,28 +78,21 @@ fun SeriesOverview( ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() } val extrasRowFocusRequester = remember { FocusRequester() } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - - val series by viewModel.item.observeAsState(null) - val seasons by viewModel.seasons.observeAsState(emptyList()) - val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) - val seasonExtras by viewModel.extras.observeAsState(emptyList()) - val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(emptyList()) - val episodeList = (episodes as? EpisodeList.Success)?.episodes + val state by viewModel.state.collectAsState() + val episodeList = + remember(state.episodes) { (state.episodes as? EpisodeList.Success)?.episodes } val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) val currentPosition by rememberUpdatedState(position) LaunchedEffect(Unit) { - if (seasons.isNotEmpty()) { - seasons.getOrNull(position.seasonTabIndex)?.let { + if (state.seasons.isNotEmpty()) { + state.seasons.getOrNull(position.seasonTabIndex)?.let { viewModel.loadEpisodes(it.id) } } @@ -112,7 +101,7 @@ fun SeriesOverview( var overviewDialog by remember { mutableStateOf(null) } var showContextMenu by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var rowFocused by rememberInt() @@ -149,7 +138,7 @@ fun SeriesOverview( onShowOverview = { overviewDialog = ItemDetailsDialogInfo(it) }, onClearChosenStreams = { val focusedEpisode = - (episodes as? EpisodeList.Success) + (state.episodes as? EpisodeList.Success) ?.episodes ?.getOrNull(currentPosition.episodeRowIndex) if (focusedEpisode != null) { @@ -159,9 +148,9 @@ fun SeriesOverview( ) } - LaunchedEffect(position, episodes) { + LaunchedEffect(position, state.episodes) { val focusedEpisode = - (episodes as? EpisodeList.Success) + (state.episodes as? EpisodeList.Success) ?.episodes ?.getOrNull(position.episodeRowIndex) @@ -170,93 +159,126 @@ fun SeriesOverview( viewModel.lookupPeopleInEpisode(it) } } - val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val chosenStreams = state.chosenStreams - val preferredSubtitleLanguage = - viewModel.serverRepository.currentUserDto - .observeAsState() - .value - ?.configuration - ?.subtitleLanguagePreference + val userDto by viewModel.serverRepository.currentUserDtoFlow.collectAsState(null) + val preferredSubtitleLanguage = userDto?.configuration?.subtitleLanguagePreference - when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state, modifier) + when (val st = state.series) { + is DataLoadingState.Error -> { + ErrorMessage(st, modifier) } - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage(modifier) } - LoadingState.Success -> { - series?.let { series -> + is DataLoadingState.Success -> { + RequestOrRestoreFocus( + when (rowFocused) { + EPISODE_ROW -> episodeRowFocusRequester + CAST_AND_CREW_ROW -> castCrewRowFocusRequester + GUEST_STAR_ROW -> guestStarRowFocusRequester + EXTRAS_ROW -> extrasRowFocusRequester + else -> episodeRowFocusRequester + }, + "series_overview", + ) + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() - RequestOrRestoreFocus( - when (rowFocused) { - EPISODE_ROW -> episodeRowFocusRequester - CAST_AND_CREW_ROW -> castCrewRowFocusRequester - GUEST_STAR_ROW -> guestStarRowFocusRequester - EXTRAS_ROW -> extrasRowFocusRequester - else -> episodeRowFocusRequester - }, - "series_overview", - ) - LifecycleResumeEffect(destination.itemId) { - viewModel.onResumePage() - - onPauseOrDispose { - viewModel.release() - } + onPauseOrDispose { + viewModel.release() } + } - SeriesOverviewContent( - preferences = preferences, - series = series, - seasons = seasons, - episodes = episodes, - chosenStreams = chosenStreams, - peopleInEpisode = peopleInEpisode, - seasonExtras = seasonExtras, - position = position, - firstItemFocusRequester = firstItemFocusRequester, - episodeRowFocusRequester = episodeRowFocusRequester, - castCrewRowFocusRequester = castCrewRowFocusRequester, - guestStarRowFocusRequester = guestStarRowFocusRequester, - extrasRowFocusRequester = extrasRowFocusRequester, - onChangeSeason = { index -> - if (index != position.seasonTabIndex) { - seasons.getOrNull(index)?.let { season -> - viewModel.loadEpisodes(season.id) - viewModel.position.update { - SeriesOverviewPosition(index, 0) - } + SeriesOverviewContent( + preferences = preferences, + series = st.data, + seasons = state.seasons, + episodes = state.episodes, + chosenStreams = chosenStreams, + peopleInEpisode = state.peopleInEpisode.people, + seasonExtras = state.extras, + position = position, + firstItemFocusRequester = firstItemFocusRequester, + episodeRowFocusRequester = episodeRowFocusRequester, + castCrewRowFocusRequester = castCrewRowFocusRequester, + guestStarRowFocusRequester = guestStarRowFocusRequester, + extrasRowFocusRequester = extrasRowFocusRequester, + onChangeSeason = { index -> + if (index != position.seasonTabIndex) { + state.seasons.getOrNull(index)?.let { season -> + viewModel.loadEpisodes(season.id) + viewModel.position.update { + SeriesOverviewPosition(index, 0) } } - }, - onFocusEpisode = { episodeIndex -> - viewModel.position.update { - it.copy(episodeRowIndex = episodeIndex) - } - }, - onClick = { - rowFocused = EPISODE_ROW - val resumePosition = - it.data.userData - ?.playbackPositionTicks - ?.ticks ?: Duration.ZERO + } + }, + onFocusEpisode = { episodeIndex -> + viewModel.position.update { + it.copy(episodeRowIndex = episodeIndex) + } + }, + onClick = { + rowFocused = EPISODE_ROW + val resumePosition = + it.data.userData + ?.playbackPositionTicks + ?.ticks ?: Duration.ZERO + viewModel.navigateTo( + Destination.Playback( + it.id, + resumePosition.inWholeMilliseconds, + ), + ) + }, + onLongClick = { ep -> + showContextMenu = + ContextMenu.ForBaseItem( + fromLongClick = true, + item = ep, + chosenStreams = chosenStreams, + showGoTo = false, + showStreamChoices = true, + canDelete = viewModel.canDelete(ep, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + ) + }, + playOnClick = { resume -> + rowFocused = EPISODE_ROW + episodeList?.getOrNull(position.episodeRowIndex)?.let { + viewModel.release() viewModel.navigateTo( Destination.Playback( it.id, - resumePosition.inWholeMilliseconds, + resume.inWholeMilliseconds, ), ) - }, - onLongClick = { ep -> + } + }, + watchOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val played = it.data.userData?.played ?: false + viewModel.setWatched(it.id, !played, position.episodeRowIndex) + } + }, + favoriteOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + val favorite = it.data.userData?.isFavorite ?: false + viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) + } + }, + moreOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> showContextMenu = ContextMenu.ForBaseItem( - fromLongClick = true, + fromLongClick = false, item = ep, chosenStreams = chosenStreams, showGoTo = false, @@ -266,71 +288,31 @@ fun SeriesOverview( canRemoveNextUp = false, actions = contextActions, ) - }, - playOnClick = { resume -> - rowFocused = EPISODE_ROW - episodeList?.getOrNull(position.episodeRowIndex)?.let { - viewModel.release() - viewModel.navigateTo( - Destination.Playback( - it.id, - resume.inWholeMilliseconds, - ), - ) - } - }, - watchOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val played = it.data.userData?.played ?: false - viewModel.setWatched(it.id, !played, position.episodeRowIndex) - } - }, - favoriteOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - val favorite = it.data.userData?.isFavorite ?: false - viewModel.setFavorite(it.id, !favorite, position.episodeRowIndex) - } - }, - moreOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - showContextMenu = - ContextMenu.ForBaseItem( - fromLongClick = false, - item = ep, - chosenStreams = chosenStreams, - showGoTo = false, - showStreamChoices = true, - canDelete = viewModel.canDelete(ep, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - ) - } - }, - overviewOnClick = { - episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = ItemDetailsDialogInfo(it) - } - }, - personOnClick = { - rowFocused = - if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW - viewModel.navigateTo( - Destination.MediaItem( - it.id, - BaseItemKind.PERSON, - ), - ) - }, - onClickExtra = { _, extra -> - rowFocused = EXTRAS_ROW - viewModel.navigateTo(extra.destination) - }, - canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, - onConfirmDelete = viewModel::deleteItem, - modifier = modifier, - ) - } + } + }, + overviewOnClick = { + episodeList?.getOrNull(position.episodeRowIndex)?.let { + overviewDialog = ItemDetailsDialogInfo(it) + } + }, + personOnClick = { + rowFocused = + if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + onClickExtra = { _, extra -> + rowFocused = EXTRAS_ROW + viewModel.navigateTo(extra.destination) + }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + onConfirmDelete = viewModel::deleteItem, + modifier = modifier, + ) } } showContextMenu?.let { contextMenu -> @@ -345,7 +327,7 @@ fun SeriesOverview( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto.value + userDto ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 8852e89b..106d13ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context import android.widget.Toast -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem @@ -28,7 +28,6 @@ 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.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault @@ -36,14 +35,13 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler -import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.successValue import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -57,6 +55,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOf @@ -84,7 +83,7 @@ import java.util.UUID class SeriesViewModel @AssistedInject constructor( - api: ApiClient, + private val api: ApiClient, @param:ApplicationContext val context: Context, val serverRepository: ServerRepository, private val navigationManager: NavigationManager, @@ -103,7 +102,7 @@ class SeriesViewModel @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, - ) : ItemViewModel(api) { + ) : ViewModel() { @AssistedFactory interface Factory { fun create( @@ -113,40 +112,28 @@ class SeriesViewModel ): SeriesViewModel } - val loading = MutableLiveData(LoadingState.Loading) - val seasons = MutableLiveData>(listOf()) - val episodes = MutableLiveData(EpisodeList.Loading) - - val trailers = MutableLiveData>(listOf()) - val extras = MutableLiveData>(listOf()) - val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>() - val canDeleteSeries = MutableStateFlow(false) - - val peopleInEpisode = MutableLiveData(PeopleInItem()) - val discovered = MutableStateFlow>(listOf()) - val discoverSeries = MutableStateFlow(null) + private val _state = MutableStateFlow(SeriesState()) + val state: StateFlow = _state val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) init { - viewModelScope.launch( - LoadingExceptionHandler( - loading, - "Error loading series $seriesId", - ) + Dispatchers.IO, - ) { + viewModelScope.launchIO { Timber.v("Start") addCloseable { themeSongPlayer.stop() } - val item = fetchItem(seriesId) + val series = + api.userLibraryApi + .getItem(seriesId) + .content + .let { BaseItem(it) } viewModelScope.launchDefault { - mediaManagementService.collectCanDelete(flowOf(item)) { canDelete -> - canDeleteSeries.update { canDelete } + mediaManagementService.collectCanDelete(flowOf(series)) { canDelete -> + _state.update { it.copy(canDeleteSeries = canDelete) } } } - backdropService.submit(item) + backdropService.submit(series) - val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) + val seasonsDeferred = getSeasons(series, seasonEpisodeIds?.seasonNumber) val episodeListDeferred = if (seriesPageType == SeriesPageType.OVERVIEW) { @@ -191,57 +178,58 @@ class SeriesViewModel } viewModelScope.launchIO { val extras = extrasService.getExtras(seasonEpisodeIds.seasonId) - this@SeriesViewModel.extras.setValueOnMain(extras) + _state.update { it.copy(extras = extras) } } } - val remoteTrailers = trailerService.getRemoteTrailers(item) - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = remoteTrailers - this@SeriesViewModel.position.update { - it.copy( - episodeRowIndex = - (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, - ) - } - this@SeriesViewModel.seasons.value = seasons - this@SeriesViewModel.episodes.value = episodes - loading.value = LoadingState.Success + val remoteTrailers = trailerService.getRemoteTrailers(series) + this@SeriesViewModel.position.update { + it.copy( + episodeRowIndex = + (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, + ) } + _state.update { + it.copy( + series = DataLoadingState.Success(series), + seasons = seasons, + episodes = episodes, + trailers = remoteTrailers, + ) + } + if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { - trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers - } + trailerService.getLocalTrailers(series).letNotEmpty { localTrailers -> + _state.update { it.copy(trailers = localTrailers + remoteTrailers) } } } viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@SeriesViewModel.people.setValueOnMain(people) + val people = peopleFavorites.getPeopleFor(series) + _state.update { it.copy(people = people) } } viewModelScope.launchIO { - val extras = extrasService.getExtras(item.id) - this@SeriesViewModel.extras.setValueOnMain(extras) + val extras = extrasService.getExtras(series.id) + _state.update { it.copy(extras = extras) } } - if (!similar.isInitialized) { + if (state.value.similar.isEmpty()) { viewModelScope.launchIO { val similar = api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, itemId = seriesId, fields = SlimItemFields, limit = 25, ), ).content.items - .map { BaseItem.from(it, api, true) } - this@SeriesViewModel.similar.setValueOnMain(similar) + .map { BaseItem(it, true) } + _state.update { it.copy(similar = similar) } } } viewModelScope.launchIO { - val results = seerrService.similar(item).orEmpty() - discovered.update { results } + val results = seerrService.similar(series).orEmpty() + _state.update { it.copy(discovered = results) } } viewModelScope.launchIO { seerrService.active.collectLatest { active -> @@ -249,7 +237,7 @@ class SeriesViewModel if (active) { try { seerrService - .getTvSeries(item) + .getTvSeries(series) ?.let { seerrService.createDiscoverItem(it) } } catch (ex: Exception) { Timber.e(ex) @@ -258,7 +246,7 @@ class SeriesViewModel } else { null } - discoverSeries.update { tv } + _state.update { it.copy(discoverSeries = tv) } } } } @@ -270,8 +258,8 @@ class SeriesViewModel deletedItem.item.id, seriesId, ) - val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + val seasons = getSeasons(series, seasonEpisodeIds?.seasonNumber).await() + _state.update { it.copy(seasons = seasons) } } }.catch { ex -> Timber.e(ex, "Error refreshing after deleted item") @@ -280,7 +268,7 @@ class SeriesViewModel } fun onResumePage() { - item.value?.let { item -> + state.value.series.successValue?.let { item -> viewModelScope.launchDefault { backdropService.submit(item) } viewModelScope.launchDefault { themeSongPlayer.playThemeFor(seriesId) @@ -289,11 +277,9 @@ class SeriesViewModel } fun refresh() { - item.value?.let { item -> - if (loading.value == LoadingState.Success) { - viewModelScope.launchIO { - (seasons.value as? ApiRequestPager<*>)?.refresh() - } + state.value.series.successValue?.let { item -> + viewModelScope.launchIO { + (state.value.seasons as? ApiRequestPager<*>)?.refresh() } } } @@ -389,11 +375,15 @@ class SeriesViewModel } fun loadEpisodes(seasonId: UUID) { - val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success) + val currentEpisodes = (state.value.episodes as? EpisodeList.Success) if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() - this@SeriesViewModel.episodes.value = EpisodeList.Loading - this@SeriesViewModel.extras.value = emptyList() + _state.update { + it.copy( + peopleInEpisode = PeopleInItem(), + episodes = EpisodeList.Loading, + extras = emptyList(), + ) + } } viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = @@ -403,13 +393,11 @@ class SeriesViewModel Timber.e(e, "Error loading episodes for $seriesId for season $seasonId") EpisodeList.Error(e) } - withContext(Dispatchers.Main) { - this@SeriesViewModel.episodes.value = episodes - } + _state.update { it.copy(episodes = episodes) } } viewModelScope.launchIO { val extras = extrasService.getExtras(seasonId) - this@SeriesViewModel.extras.setValueOnMain(extras) + _state.update { it.copy(extras = extras) } } } @@ -424,6 +412,30 @@ class SeriesViewModel } } + private fun updateSeries() { + viewModelScope.launchIO { + try { + val series = + api.userLibraryApi + .getItem(seriesId) + .content + .let(::BaseItem) + _state.update { it.copy(series = DataLoadingState.Success(series)) } + viewModelScope.launchIO { + val people = peopleFavorites.getPeopleFor(series) + _state.update { it.copy(people = people) } + } + viewModelScope.launchIO { + val seasons = getSeasons(series, null).await() + _state.update { it.copy(seasons = seasons) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error updating series") + showToast(context, "Error updating series") + } + } + } + fun setFavorite( itemId: UUID, favorite: Boolean, @@ -433,11 +445,7 @@ class SeriesViewModel if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { - val item = fetchItem(seriesId) - viewModelScope.launchIO { - val people = peopleFavorites.getPeopleFor(item) - this@SeriesViewModel.people.setValueOnMain(people) - } + updateSeries() } } @@ -446,32 +454,27 @@ class SeriesViewModel played: Boolean, ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) - val series = fetchItem(seriesId) - val seasons = getSeasons(series, null).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + updateSeries() } fun setWatchedSeries(played: Boolean) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) - val series = fetchItem(seriesId) - val seasons = getSeasons(series, null).await() - this@SeriesViewModel.seasons.setValueOnMain(seasons) + updateSeries() } fun refreshEpisode( itemId: UUID, listIndex: Int, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - val eps = episodes.value + val eps = state.value.episodes if (eps is EpisodeList.Success) { eps.episodes.refreshItem(listIndex, itemId) - withContext(Dispatchers.Main) { - episodes.value = eps - } + _state.update { it.copy(episodes = eps) } } // Kind of hack to ensure the backdrop is reloaded if needed - item.value?.let { backdropService.submit(it) } + state.value.series.successValue + ?.let { backdropService.submit(it) } } /** @@ -506,7 +509,6 @@ class SeriesViewModel navigationManager.navigateTo(destination) } - val chosenStreams = MutableLiveData(null) private var chosenStreamsJob: Job? = null fun lookUpChosenTracks( @@ -522,9 +524,7 @@ class SeriesViewModel item, userPreferencesService.getCurrent(), ) - withContext(Dispatchers.Main) { - chosenStreams.value = result - } + _state.update { it.copy(chosenStreams = result) } } } @@ -540,9 +540,7 @@ class SeriesViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -566,9 +564,7 @@ class SeriesViewModel result?.let { itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) } - withContext(Dispatchers.Main) { - chosenStreams.value = chosen - } + _state.update { it.copy(chosenStreams = chosen) } } } @@ -581,8 +577,8 @@ class SeriesViewModel suspend fun lookupPeopleInEpisode(item: BaseItem) { peopleInEpisodeJob?.cancel() - if (peopleInEpisode.value?.itemId != item.id) { - peopleInEpisode.setValueOnMain(PeopleInItem()) + if (state.value.peopleInEpisode.itemId != item.id) { + _state.update { it.copy(peopleInEpisode = PeopleInItem()) } val result = peopleInEpisodeCache .get(item.id) { @@ -600,7 +596,8 @@ class SeriesViewModel peopleInEpisodeJob = viewModelScope.launch(ExceptionHandler()) { delay(250) - peopleInEpisode.setValueOnMain(result.await()) + val peopleInEpisode = result.await() + _state.update { it.copy(peopleInEpisode = peopleInEpisode) } } } } @@ -621,17 +618,17 @@ class SeriesViewModel if (item.type == BaseItemKind.SERIES) { navigationManager.goBack() } else if (seriesPageType == SeriesPageType.DETAILS) { - this@SeriesViewModel.item.value?.let { series -> + state.value.series.successValue?.let { series -> val seasons = getSeasons(series, null).await() if (seasons.isEmpty()) { navigationManager.goBack() } else { - this@SeriesViewModel.seasons.setValueOnMain(seasons) + _state.update { it.copy(seasons = seasons) } } } } else { position.value.let { (_, episodeIndex) -> - val eps = episodes.value as? EpisodeList.Success + val eps = state.value.episodes as? EpisodeList.Success if (eps != null) { val pager = eps.episodes val lastIndex = pager.lastIndex @@ -641,22 +638,28 @@ class SeriesViewModel } else { if (episodeIndex == lastIndex) { // Deleted last episode, so need to move left - episodes.setValueOnMain( - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex - 1, - ), - ) + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + } position.update { it.copy(episodeRowIndex = episodeIndex - 1) } } else { - episodes.setValueOnMain( - EpisodeList.Success( - eps.seasonId, - pager, - episodeIndex, - ), - ) + _state.update { + it.copy( + episodes = + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } } } } @@ -744,3 +747,18 @@ private suspend fun findIndexOf( } return index } + +data class SeriesState( + val series: DataLoadingState = DataLoadingState.Pending, + val seasons: List = emptyList(), + val episodes: EpisodeList = EpisodeList.Loading, + val trailers: List = emptyList(), + val extras: List = emptyList(), + val people: List = emptyList(), + val similar: List = emptyList(), + val canDeleteSeries: Boolean = false, + val peopleInEpisode: PeopleInItem = PeopleInItem(), + val discovered: List = emptyList(), + val discoverSeries: DiscoverItem? = null, + val chosenStreams: ChosenStreams? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cfd0de03..4aa52ade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -23,7 +23,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -77,7 +76,6 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.PlaylistDialog -import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -128,7 +126,7 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf(null) } var overviewDialog by remember { mutableStateOf(null) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val playlistState by playlistViewModel.playlistState.collectAsState() var position by rememberPosition() val onFocusPosition = remember { { it: RowColumn -> position = it } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index f6866b12..5ce045fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -79,7 +79,7 @@ class HomeViewModel val preferences = userPreferencesService.getCurrent() val prefs = preferences.appPreferences.homePagePreferences - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUserDto?.let { userDto -> val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val settings = @@ -256,7 +256,7 @@ class HomeViewModel fun removeFromNextUp(item: BaseItem) { if (item.type == BaseItemKind.EPISODE) { viewModelScope.launchDefault { - serverRepository.currentUser.value?.id?.let { userId -> + serverRepository.currentUser?.id?.let { userId -> latestNextUpService.removeFromNextUp(userId, item) init() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 3a2c8f45..9eef0972 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,7 +57,6 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import androidx.datastore.core.DataStore import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope @@ -102,14 +100,17 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.preferences.SwitchColors import com.github.damontecres.wholphin.ui.rememberPosition -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.SearchRelevance import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -135,15 +136,8 @@ class SearchViewModel val partialResult = voiceInputManager.partialResult val seerrActive = seerrService.active - val movies = MutableLiveData(SearchResult.NoQuery) - val series = MutableLiveData(SearchResult.NoQuery) - val episodes = MutableLiveData(SearchResult.NoQuery) - val collections = MutableLiveData(SearchResult.NoQuery) - val albums = MutableLiveData(SearchResult.NoQuery) - val artists = MutableLiveData(SearchResult.NoQuery) - val songs = MutableLiveData(SearchResult.NoQuery) - val seerrResults = MutableLiveData(SearchResult.NoQuery) - val combinedResults = MutableLiveData(SearchResult.NoQuery) + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state private var currentQuery: String? = null private var combinedMode = false @@ -159,38 +153,76 @@ class SearchViewModel combinedMode = combined if (query.isNotNullOrBlank()) { if (combined) { - combinedResults.value = SearchResult.Searching + _state.update { it.copy(combinedResults = SearchResult.Searching) } searchCombined(query) } else { - movies.value = SearchResult.Searching - series.value = SearchResult.Searching - episodes.value = SearchResult.Searching - collections.value = SearchResult.Searching - searchInternal(query, BaseItemKind.MOVIE, movies) - searchInternal(query, BaseItemKind.SERIES, series) - searchInternal(query, BaseItemKind.EPISODE, episodes) - searchInternal(query, BaseItemKind.BOX_SET, collections) - searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) - searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) - searchInternal(query, BaseItemKind.AUDIO, songs) + _state.update { + it.copy( + movies = SearchResult.Searching, + series = SearchResult.Searching, + episodes = SearchResult.Searching, + collections = SearchResult.Searching, + albums = SearchResult.Searching, + artists = SearchResult.Searching, + songs = SearchResult.Searching, + ) + } + searchInternal( + query, + BaseItemKind.MOVIE, + ) { result, state -> state.copy(movies = result) } + searchInternal( + query, + BaseItemKind.SERIES, + ) { result, state -> state.copy(series = result) } + searchInternal(query, BaseItemKind.EPISODE) { result, state -> + state.copy( + episodes = result, + ) + } + searchInternal(query, BaseItemKind.BOX_SET) { result, state -> + state.copy( + collections = result, + ) + } + searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state -> + state.copy( + albums = result, + ) + } + searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state -> + state.copy( + artists = result, + ) + } + searchInternal( + query, + BaseItemKind.AUDIO, + ) { result, state -> state.copy(songs = result) } } searchSeerr(query) } else { - movies.value = SearchResult.NoQuery - series.value = SearchResult.NoQuery - episodes.value = SearchResult.NoQuery - collections.value = SearchResult.NoQuery - seerrResults.value = SearchResult.NoQuery - combinedResults.value = SearchResult.NoQuery + _state.update { + it.copy( + combinedResults = SearchResult.NoQuery, + movies = SearchResult.NoQuery, + series = SearchResult.NoQuery, + episodes = SearchResult.NoQuery, + collections = SearchResult.NoQuery, + albums = SearchResult.NoQuery, + artists = SearchResult.NoQuery, + songs = SearchResult.NoQuery, + ) + } } } private fun searchInternal( query: String, type: BaseItemKind, - target: MutableLiveData, + update: (SearchResult, SearchState) -> SearchState, ) { - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + viewModelScope.launchIO { try { val request = GetItemsRequest( @@ -210,14 +242,12 @@ class SearchViewModel compareBy { SearchRelevance.score(it, query) } .thenBy { it.name ?: "" }, ) - withContext(Dispatchers.Main) { - target.value = SearchResult.Success(sorted) - } + _state.update { update.invoke(SearchResult.Success(sorted), it) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Exception searching for $type") - withContext(Dispatchers.Main) { - target.value = SearchResult.Error(ex) - } + _state.update { update.invoke(SearchResult.Error(ex), it) } } } } @@ -249,15 +279,10 @@ class SearchViewModel compareBy { SearchRelevance.score(it, query) } .thenBy { it.name ?: "" }, ) - - withContext(Dispatchers.Main) { - combinedResults.value = SearchResult.Success(sorted) - } + _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } } catch (ex: Exception) { Timber.e(ex, "Exception in combined search") - withContext(Dispatchers.Main) { - combinedResults.value = SearchResult.Error(ex) - } + _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } } } } @@ -285,13 +310,13 @@ class SearchViewModel private fun searchSeerr(query: String) { viewModelScope.launchIO { if (seerrService.active.first()) { - seerrResults.setValueOnMain(SearchResult.Searching) + _state.update { it.copy(seerrResults = SearchResult.Searching) } val results = seerrService .search(query) .map { seerrService.createDiscoverItem(it) } .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) + _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } } } } @@ -324,6 +349,18 @@ sealed interface SearchResult { ) : SearchResult } +data class SearchState( + val movies: SearchResult = SearchResult.NoQuery, + val series: SearchResult = SearchResult.NoQuery, + val episodes: SearchResult = SearchResult.NoQuery, + val collections: SearchResult = SearchResult.NoQuery, + val albums: SearchResult = SearchResult.NoQuery, + val artists: SearchResult = SearchResult.NoQuery, + val songs: SearchResult = SearchResult.NoQuery, + val seerrResults: SearchResult = SearchResult.NoQuery, + val combinedResults: SearchResult = SearchResult.NoQuery, +) + private const val SEARCH_ROW = 0 private const val TAB_ROW = SEARCH_ROW + 1 private const val MOVIE_ROW = TAB_ROW + 1 @@ -348,15 +385,7 @@ fun SearchPage( ) { val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current - val movies by viewModel.movies.observeAsState(SearchResult.NoQuery) - val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) - val series by viewModel.series.observeAsState(SearchResult.NoQuery) - val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) - val albums by viewModel.albums.observeAsState(SearchResult.NoQuery) - val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) - val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) - val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) - val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery) + val state by viewModel.state.collectAsState() // Start with current preferences, but collect updates when view options change val prefs = @@ -441,12 +470,7 @@ fun SearchPage( LaunchedEffect( searchClicked, - movies, - collections, - series, - episodes, - seerrResults, - combinedResults, + state, combinedMode, selectedTab, seerrActive, @@ -458,12 +482,20 @@ fun SearchPage( val results = if (isLibraryTab) { if (combinedMode) { - listOf(combinedResults) + listOf(state.combinedResults) } else { - listOf(movies, series, episodes, collections) + listOf( + state.movies, + state.series, + state.episodes, + state.collections, + state.albums, + state.artists, + state.songs, + ) } } else { - listOf(seerrResults) + listOf(state.seerrResults) } val firstSuccess = results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } @@ -609,7 +641,7 @@ fun SearchPage( when { isLibraryTab && combinedMode -> { SearchCombinedResults( - result = combinedResults, + result = state.combinedResults, focusRequester = focusRequesters[COMBINED_ROW], onClickItem = onClickItem, onPlayItem = onPlayItem, @@ -622,7 +654,7 @@ fun SearchPage( !isLibraryTab && combinedMode -> { SearchCombinedResults( - result = seerrResults, + result = state.seerrResults, focusRequester = focusRequesters[SEERR_ROW], onClickItem = onClickItem, onPlayItem = onPlayItem, @@ -647,7 +679,7 @@ fun SearchPage( ) { searchResultRow( title = R.string.movies, - result = movies, + result = state.movies, rowIndex = MOVIE_ROW, position = position, focusRequester = focusRequesters[MOVIE_ROW], @@ -657,7 +689,7 @@ fun SearchPage( ) searchResultRow( title = R.string.tv_shows, - result = series, + result = state.series, rowIndex = SERIES_ROW, position = position, focusRequester = focusRequesters[SERIES_ROW], @@ -667,7 +699,7 @@ fun SearchPage( ) searchResultRow( title = R.string.episodes, - result = episodes, + result = state.episodes, rowIndex = EPISODE_ROW, position = position, focusRequester = focusRequesters[EPISODE_ROW], @@ -689,7 +721,7 @@ fun SearchPage( ) searchResultRow( title = R.string.collections, - result = collections, + result = state.collections, rowIndex = COLLECTION_ROW, position = position, focusRequester = focusRequesters[COLLECTION_ROW], @@ -699,7 +731,7 @@ fun SearchPage( ) searchResultRow( title = R.string.albums, - result = albums, + result = state.albums, rowIndex = ALBUM_ROW, position = position, focusRequester = focusRequesters[ALBUM_ROW], @@ -723,7 +755,7 @@ fun SearchPage( ) searchResultRow( title = R.string.artists, - result = artists, + result = state.artists, rowIndex = ARTIST_ROW, position = position, focusRequester = focusRequesters[ARTIST_ROW], @@ -747,7 +779,7 @@ fun SearchPage( ) searchResultRow( title = R.string.songs, - result = songs, + result = state.songs, rowIndex = SONG_ROW, position = position, focusRequester = focusRequesters[SONG_ROW], @@ -771,7 +803,7 @@ fun SearchPage( ) searchResultRow( title = R.string.discover, - result = seerrResults, + result = state.seerrResults, rowIndex = SEERR_ROW, position = position, focusRequester = focusRequesters[SEERR_ROW], diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 6cf01c24..cd809e76 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -94,7 +94,7 @@ class HomeSettingsViewModel init { addCloseable { saveToLocal() } viewModelScope.launchIO { - val userDto = serverRepository.currentUserDto.value ?: return@launchIO + val userDto = serverRepository.currentUserDto ?: return@launchIO val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } @@ -122,7 +122,7 @@ class HomeSettingsViewModel val limit = 8 val semaphore = Semaphore(4) val rows = - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUserDto?.let { userDto -> val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences state.value .let { state -> @@ -508,7 +508,7 @@ class HomeSettingsViewModel fun saveToRemote() { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Saving home settings to remote") val rows = state.value.rows.map { it.config } val settings = @@ -527,7 +527,7 @@ class HomeSettingsViewModel fun loadFromRemote() { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Loading home settings from remote") try { _state.update { it.copy(loading = LoadingState.Loading) } @@ -561,7 +561,7 @@ class HomeSettingsViewModel fun loadFromRemoteWeb() { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> Timber.d("Loading home settings from web") try { _state.update { it.copy(loading = LoadingState.Loading) } @@ -588,7 +588,7 @@ class HomeSettingsViewModel fun saveToLocal() { // This uses injected ioScope so that it will still run when the page is closing ioScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val rows = state.value.rows.map { it.config } val settings = HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) @@ -655,7 +655,7 @@ class HomeSettingsViewModel fun resetToDefault() = viewModelScope.launchIO { - val userId = serverRepository.currentUser.value?.id ?: return@launchIO + val userId = serverRepository.currentUser?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } val result = homeSettingsService.createDefault(userId) idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt index 605f76d5..e7b3c07c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.tv.material3.Icon import androidx.tv.material3.ListItem @@ -75,7 +74,7 @@ class RemovedNextUpContentViewModel init { viewModelScope.launchDefault { - serverRepository.currentUser.asFlow().collectLatest { user -> + serverRepository.currentUserFlow.collectLatest { user -> _state.update { RemovedNextUpState() } if (user == null) { return@collectLatest @@ -105,7 +104,7 @@ class RemovedNextUpContentViewModel } fun remove(item: RemovedItem) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> viewModelScope.launchDefault { mutex.withLock { _state.update { it.copy(removedEnabled = false) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 26068182..af4ec63f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -35,7 +35,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -56,7 +55,6 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner import androidx.lifecycle.viewModelScope @@ -86,13 +84,15 @@ import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType @@ -111,10 +111,10 @@ class NavDrawerViewModel val backdropService: BackdropService, private val musicService: MusicService, ) : ViewModel() { - val state = navDrawerService.state + val serviceState = navDrawerService.state - val selectedIndex = MutableLiveData(-1) - val moreExpanded = MutableLiveData(false) + private val _state = MutableStateFlow(NavDrawerState()) + val state: StateFlow = _state fun onClickDrawerItem( index: Int, @@ -130,7 +130,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!moreExpanded.value!!) + setShowMore(!state.value.moreExpanded) } NavDrawerItem.Discover -> { @@ -148,11 +148,11 @@ class NavDrawerViewModel } fun setIndex(index: Int) { - selectedIndex.value = index + _state.update { it.copy(selectedIndex = index) } } fun setShowMore(value: Boolean) { - moreExpanded.value = value + _state.update { it.copy(moreExpanded = value) } } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) @@ -164,11 +164,11 @@ class NavDrawerViewModel viewModelScope.launchDefault { val asDestinations = ( - state.value.items + + serviceState.value.items + listOf( NavDrawerItem.More, NavDrawerItem.Discover, - ) + state.value.moreItems + ) + serviceState.value.moreItems ).map { if (it is ServerNavDrawerItem) { it.destination @@ -202,7 +202,7 @@ class NavDrawerViewModel } Timber.v("Found $index => $key") if (index != null) { - selectedIndex.setValueOnMain(index) + _state.update { it.copy(selectedIndex = index) } break } } @@ -218,6 +218,11 @@ class NavDrawerViewModel } } +data class NavDrawerState( + val selectedIndex: Int = -1, + val moreExpanded: Boolean = false, +) + /** * An item that can be shown in the nav drawer * @@ -303,10 +308,11 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } + val serviceState by viewModel.serviceState.collectAsState() val state by viewModel.state.collectAsState() - val moreExpanded by viewModel.moreExpanded.observeAsState(false) + val moreExpanded = state.moreExpanded // A negative index is a built-in page, >=0 is a library - val selectedIndex by viewModel.selectedIndex.observeAsState(-1) + val selectedIndex = state.selectedIndex BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) @@ -361,14 +367,14 @@ fun NavDrawer( modifier = Modifier, ) AnimatedVisibility( - visible = state.nowPlayingEnabled, + visible = serviceState.nowPlayingEnabled, enter = expandVertically(expandFrom = Alignment.Top), exit = shrinkVertically(shrinkTowards = Alignment.Top), ) { val interactionSource = remember { MutableInteractionSource() } IconNavItem( text = stringResource(R.string.now_playing), - subtext = state.nowPlayingTitle, + subtext = serviceState.nowPlayingTitle, icon = Icons.Default.PlayArrow, selected = selectedIndex == NOW_PLAYING_INDEX, drawerOpen = isOpen, @@ -448,8 +454,8 @@ fun NavDrawer( ), ) } - itemsIndexed(state.items) { index, it -> - if (it !is NavDrawerItem.Discover || state.discoverEnabled) { + itemsIndexed(serviceState.items) { index, it -> + if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, @@ -469,9 +475,9 @@ fun NavDrawer( ) } } - if (state.moreItems.isNotEmpty()) { + if (serviceState.moreItems.isNotEmpty()) { item { - val index = state.items.size + val index = serviceState.items.size val interactionSource = remember { MutableInteractionSource() } NavItem( library = NavDrawerItem.More, @@ -492,10 +498,10 @@ fun NavDrawer( } } if (moreExpanded) { - itemsIndexed(state.moreItems) { index, it -> + itemsIndexed(serviceState.moreItems) { index, it -> val adjustedIndex = - remember(state) { (index + state.items.size + 1) } - if (it !is NavDrawerItem.Discover || state.discoverEnabled) { + remember(serviceState) { (index + serviceState.items.size + 1) } + if (it !is NavDrawerItem.Discover || serviceState.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index d4039f72..c9845191 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -59,6 +59,8 @@ fun DownloadSubtitlesContent( modifier: Modifier = Modifier, ) { when (val s = state) { + SubtitleSearchStatus.Inactive -> {} + SubtitleSearchStatus.Searching -> { Wrapper { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 915fc5a7..a9e4b61d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -142,7 +142,7 @@ class PlayExternalViewModel throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } val playbackConfig = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> itemPlaybackDao.getItem(user, playlistItem.id)?.let { Timber.v("Fetched itemPlayback from DB: %s", it) if (it.sourceId != null) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index f8610e71..cc004ef2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -51,7 +50,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -69,8 +67,6 @@ import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation -import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences @@ -101,7 +97,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber -import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -125,9 +120,8 @@ fun PlaybackPage( viewModel.release() } } - - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - when (val st = loading) { + val state by viewModel.state.collectAsState() + when (val st = state.loading) { is LoadingState.Error -> { ErrorMessage(st, modifier) } @@ -141,7 +135,7 @@ fun PlaybackPage( LoadingState.Success -> { val playerState by viewModel.currentPlayer.collectAsState() PlaybackPageContent( - playerState = playerState!!, + playerInstance = playerState!!, preferences = preferences, destination = destination, viewModel = viewModel, @@ -154,41 +148,25 @@ fun PlaybackPage( @OptIn(UnstableApi::class) @Composable fun PlaybackPageContent( - playerState: PlayerState, + playerInstance: PlayerInstance, preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, viewModel: PlaybackViewModel, ) { - val player = playerState.player - val playerBackend = playerState.backend + val state by viewModel.state.collectAsState() + val subtitleSearchState by viewModel.subtitleSearchState.collectAsState() + val player = playerInstance.player + val playerBackend = playerInstance.backend val prefs = preferences.appPreferences.playbackPreferences val scope = rememberCoroutineScope() val configuration = LocalConfiguration.current val density = LocalDensity.current - val mediaInfo by viewModel.currentMediaInfo.observeAsState() - val userDto by viewModel.currentUserDto.observeAsState() + val userDto by viewModel.currentUserDto.collectAsState() - val currentPlayback by viewModel.currentPlayback.collectAsState() - val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( - ItemPlayback( - userId = -1, - itemId = UUID.randomUUID(), - ), - ) - val currentSegment by viewModel.currentSegment.collectAsState() - val analyticsState by viewModel.analyticsState.collectAsState() - - val cues by viewModel.subtitleCues.observeAsState(listOf()) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - val nextUp by viewModel.nextUp.observeAsState(null) - val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - - val subtitleSearch by viewModel.subtitleSearchStatus.observeAsState(null) - val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) - var playbackDialog by remember { mutableStateOf(null) } LaunchedEffect(player) { if (playerBackend == PlayerBackend.MPV) { @@ -214,16 +192,16 @@ fun PlaybackPageContent( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO - LaunchedEffect(subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = subtitleDelay + LaunchedEffect(state.currentPlayback?.subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = + state.currentPlayback?.subtitleDelay ?: Duration.ZERO } val presentationState = rememberPresentationState(player, false) - val playbackState by rememberPlaybackState(player) + val playbackState by rememberPlayerState(player) var showBuffering by remember { mutableStateOf(false) } LaunchedEffect(playbackState) { - if (playbackState == PlaybackState.BUFFERING) { + if (playbackState == PlayerState.BUFFERING) { // Delay before showing the loading indicator // So if buffering is quick, the UI won't flash delay(250) @@ -259,7 +237,7 @@ fun PlaybackPageContent( val keyHandler = PlaybackKeyHandler( player = player, - controlsEnabled = nextUp == null, + controlsEnabled = state.nextUp == null, skipWithLeftRight = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -318,7 +296,7 @@ fun PlaybackPageContent( PlaybackAction.Previous -> { val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + if (pos < player.maxSeekToPreviousPosition && state.playlist.hasPrevious()) { viewModel.playPrevious() } else { player.seekToPrevious() @@ -328,17 +306,17 @@ fun PlaybackPageContent( } val showSegment = - currentSegment?.interacted == false && - nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + state.currentSegment?.interacted == false && + state.nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L BackHandler(showSegment) { - viewModel.updateSegment(currentSegment?.segment?.id, true) + viewModel.updateSegment(state.currentSegment?.segment?.id, true) } Box( modifier - .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), + .background(if (state.nextUp == null) Color.Black else MaterialTheme.colorScheme.background), ) { - val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) + val playerSize by animateFloatAsState(if (state.nextUp == null) 1f else .6f) Box( modifier = Modifier @@ -427,12 +405,12 @@ fun PlaybackPageContent( .padding(WindowInsets.systemBars.asPaddingValues()) .fillMaxSize() .background(Color.Transparent), - item = currentPlayback?.item, + item = state.currentPlayback?.item, player = player, controllerViewState = controllerViewState, showPlay = playPauseState.showPlay, previousEnabled = true, - nextEnabled = playlist.hasNext(), + nextEnabled = state.playlist.hasNext(), seekEnabled = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, @@ -441,23 +419,23 @@ fun PlaybackPageContent( onClickPlaybackDialogType = { playbackDialog = it }, onSeekBarChange = seekBarState::onValueChange, showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, + currentPlayback = state.currentPlayback, + chapters = state.currentMediaInfo.chapters, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, + playlist = state.playlist, onClickPlaylist = { viewModel.playItemInPlaylist(it) }, - currentSegment = currentSegment?.segment, + currentSegment = state.currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, - analyticsState = analyticsState, + analyticsState = state.analyticsState, ) val subtitleSettings = - remember(mediaInfo) { - Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") - if (mediaInfo?.videoStream?.hdr == true) { + remember(state.currentMediaInfo) { + Timber.v("subtitle choice: ${state.currentMediaInfo.videoStream?.hdr}") + if (state.currentMediaInfo.videoStream?.hdr == true) { preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences } else { preferences.appPreferences.interfacePreferences.subtitlesPreferences @@ -468,10 +446,14 @@ fun PlaybackPageContent( // Subtitles val subtitleMaxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } + val isImageSubtitles = + remember(state.subtitleCues) { state.subtitleCues.firstOrNull()?.bitmap != null } var cueCount by remember { mutableIntStateOf(0) } - val subtitleVisible = skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled && !presentationState.coverSurface + val subtitleVisible = + skipIndicatorDuration == 0L && + state.currentItemPlayback?.subtitleIndexEnabled == true && + !presentationState.coverSurface AndroidView( factory = { context -> @@ -481,7 +463,7 @@ fun PlaybackPageContent( setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) } - playerState.assHandler?.let { assHandler -> + playerInstance.assHandler?.let { assHandler -> if (prefs.overrides.assPlaybackMode == AssPlaybackMode.ASS_LIBASS) { Timber.v("Adding AssSubtitleView") addView( @@ -499,12 +481,12 @@ fun PlaybackPageContent( } }, update = { subtitleView -> - subtitleView.setCues(cues) - if (cues.size > cueCount) { + subtitleView.setCues(state.subtitleCues) + if (state.subtitleCues.size > cueCount) { // The output creates a painter for each cue, so need to apply the changes when the number of cues increases Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) .apply(subtitleView) - cueCount = cues.size + cueCount = state.subtitleCues.size } subtitleView.children.firstOrNull { it is AssSubtitleView }?.let { (it as? AssSubtitleView)?.apply { @@ -552,7 +534,7 @@ fun PlaybackPageContent( .padding(40.dp) .align(Alignment.BottomEnd), ) { - currentSegment?.let { segment -> + state.currentSegment?.let { segment -> val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() @@ -570,7 +552,7 @@ fun PlaybackPageContent( } // Next up episode - BackHandler(nextUp != null) { + BackHandler(state.nextUp != null) { if (player.isPlaying) { scope.launch(ExceptionHandler()) { viewModel.cancelUpNextEpisode() @@ -580,12 +562,12 @@ fun PlaybackPageContent( } } AnimatedVisibility( - nextUp != null, + state.nextUp != null, modifier = Modifier .align(Alignment.BottomCenter), ) { - nextUp?.let { + state.nextUp?.let { var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } var timeLeft by remember { mutableLongStateOf( @@ -642,7 +624,7 @@ fun PlaybackPageContent( } } - subtitleSearch?.let { state -> + if (subtitleSearchState.status != SubtitleSearchStatus.Inactive) { val wasPlaying = remember { player.isPlaying } LaunchedEffect(Unit) { player.pause() @@ -661,8 +643,8 @@ fun PlaybackPageContent( ), ) { DownloadSubtitlesContent( - state = state, - language = subtitleSearchLanguage, + state = subtitleSearchState.status, + language = subtitleSearchState.language, onSearch = { lang -> viewModel.searchForSubtitles(lang) }, @@ -684,18 +666,18 @@ fun PlaybackPageContent( settings = PlaybackSettings( showDebugInfo = showDebugInfo, - audioIndex = currentItemPlayback?.audioIndex, - audioStreams = mediaInfo?.audioStreams.orEmpty(), - subtitleIndex = currentItemPlayback?.subtitleIndex, - subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), + audioIndex = state.currentItemPlayback?.audioIndex, + audioStreams = state.currentMediaInfo.audioStreams, + subtitleIndex = state.currentItemPlayback?.subtitleIndex, + subtitleStreams = state.currentMediaInfo.subtitleStreams, playbackSpeed = playbackSpeed, contentScale = contentScale, - subtitleDelay = subtitleDelay, + subtitleDelay = state.currentPlayback?.subtitleDelay ?: Duration.ZERO, hasSubtitleDownloadPermission = remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, // TODO Passing through audio prevents changing playback speed // See https://github.com/damontecres/Wholphin/issues/164 - playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, + playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, ), onDismissRequest = { playbackDialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index 8bd980be..a520df2e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -1,42 +1,47 @@ package com.github.damontecres.wholphin.ui.playback -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.ui.text.intl.Locale import androidx.media3.common.Player -import androidx.media3.common.listenTo +import androidx.media3.common.text.Cue +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.formatBitrate +import com.github.damontecres.wholphin.util.LoadingState +import io.github.peerless2012.ass.media.AssHandler +import org.jellyfin.sdk.model.api.MediaSegmentDto -/** - * Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering. - * - * @see Player.State - * @see PlaybackState - */ -@Composable -fun rememberPlaybackState(player: Player): State { - val state = remember(player) { mutableStateOf(getPlaybackState(player.playbackState)) } - LaunchedEffect(player) { - player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) { - state.value = getPlaybackState(player.playbackState) - } - } - return state -} +data class PlaybackState( + val loading: LoadingState = LoadingState.Loading, + val currentMediaInfo: CurrentMediaInfo = CurrentMediaInfo.EMPTY, + val currentPlayback: CurrentPlayback? = null, + val currentItemPlayback: ItemPlayback? = null, + val currentSegment: MediaSegmentState? = null, + val analyticsState: AnalyticsState = AnalyticsState(), + val subtitleCues: List = emptyList(), + val nextUp: BaseItem? = null, + val playlist: Playlist = Playlist(listOf()), +) -private fun getPlaybackState( - @Player.State value: Int, -): PlaybackState = PlaybackState.entries.first { it.value == value } +data class PlayerInstance( + val player: Player, + val backend: PlayerBackend, + val assHandler: AssHandler?, +) -/** - * Represents [Player.State] integers as an Enum - */ -enum class PlaybackState( - @param:Player.State val value: Int, -) { - IDLE(Player.STATE_IDLE), - BUFFERING(Player.STATE_BUFFERING), - READY(Player.STATE_READY), - ENDED(Player.STATE_ENDED), -} +data class MediaSegmentState( + val segment: MediaSegmentDto, + val interacted: Boolean, +) + +data class AnalyticsState( + val bitrate: String = formatBitrate(0), + val bitrateEstimate: String = formatBitrate(0), + val droppedFrames: Int = 0, +) + +data class SubtitleSearchState( + val status: SubtitleSearchStatus = SubtitleSearchStatus.Inactive, + val language: String = Locale.current.language, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index f6007f55..9ae8bfdd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -5,9 +5,7 @@ import android.media.MediaCodecList import android.os.Build import android.widget.Toast import androidx.annotation.OptIn -import androidx.compose.ui.text.intl.Locale import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.media3.common.C @@ -17,7 +15,6 @@ import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Tracks -import androidx.media3.common.text.Cue import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DecoderCounters @@ -62,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler @@ -79,20 +75,20 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.peerless2012.ass.media.AssHandler import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.mediaInfoApi @@ -105,7 +101,6 @@ import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType @@ -161,7 +156,7 @@ class PlaybackViewModel fun create(destination: Destination): PlaybackViewModel } - val currentPlayer = MutableStateFlow(null) + val currentPlayer = MutableStateFlow(null) internal lateinit var player: Player @@ -174,15 +169,8 @@ class PlaybackViewModel true, ) - val loading = MutableLiveData(LoadingState.Loading) - - val currentMediaInfo = MutableLiveData(CurrentMediaInfo.EMPTY) - val currentPlayback = MutableStateFlow(null) - val currentItemPlayback = MutableLiveData() - val currentSegment = MutableStateFlow(null) - val analyticsState = MutableStateFlow(AnalyticsState()) - - val subtitleCues = MutableLiveData>(listOf()) + private val _state = MutableStateFlow(PlaybackState()) + val state: StateFlow = _state private lateinit var preferences: UserPreferences internal lateinit var itemId: UUID @@ -191,14 +179,11 @@ class PlaybackViewModel private var activityListener: TrackActivityPlaybackListener? = null private val jobs = mutableListOf() - val nextUp = MutableLiveData() private val isPlaylist = destination is Destination.PlaybackList - val playlist = MutableLiveData(Playlist(listOf())) - val subtitleSearchStatus = MutableLiveData(null) - val subtitleSearchLanguage = MutableLiveData(Locale.current.language) + val subtitleSearchState = MutableStateFlow(SubtitleSearchState()) - val currentUserDto = serverRepository.currentUserDto + val currentUserDto = serverRepository.currentUserDtoFlow init { viewModelScope.launchIO { @@ -261,7 +246,7 @@ class PlaybackViewModel ) this.player = playerCreation.player currentPlayer.update { - PlayerState(playerCreation.player, playerBackend, playerCreation.assHandler) + PlayerInstance(playerCreation.player, playerBackend, playerCreation.assHandler) } configurePlayer() } @@ -288,7 +273,7 @@ class PlaybackViewModel */ private suspend fun init() { musicService.stop() - nextUp.setValueOnMain(null) + _state.update { it.copy(nextUp = null) } this.preferences = userPreferencesService.getCurrent() if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } @@ -335,7 +320,7 @@ class PlaybackViewModel ) when (val r = playlistResult) { is PlaylistCreationResult.Error -> { - loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + _state.update { it.copy(loading = LoadingState.Error(r.message, r.ex)) } return } @@ -346,8 +331,8 @@ class PlaybackViewModel return } if (preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist + _state.update { + it.copy(playlist = r.playlist) } } r.playlist.items.first() @@ -365,7 +350,7 @@ class PlaybackViewModel api.userLibraryApi .getIntros( itemId = playlistItem.id, - userId = serverRepository.currentUser.value?.id, + userId = serverRepository.currentUser?.id, ).content.items .map { PlaylistItem.Intro(BaseItem(it)) @@ -376,13 +361,9 @@ class PlaybackViewModel val firstItem = if (intros.isNotEmpty()) { Timber.v("Got %s intros", intros.size) - val currentPlaylist = - this@PlaybackViewModel - .playlist.value - ?.items - .orEmpty() - val newPlaylist = Playlist(intros + currentPlaylist) - this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist) + _state.update { + it.copy(playlist = Playlist(intros + it.playlist.items)) + } intros.first() } else { playlistItem @@ -401,17 +382,21 @@ class PlaybackViewModel if (!isPlaylist && preferences.appPreferences.playbackPreferences.showNextUpWhen != ShowNextUpWhen.NEXT_UP_NEVER) { val result = playlistCreator.createFrom(queriedItem) if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - val currentPlaylist = - this@PlaybackViewModel - .playlist.value - ?.items - .orEmpty() - val newPlaylist = Playlist(currentPlaylist + result.playlist.items) - this@PlaybackViewModel.playlist.setValueOnMain(newPlaylist) + _state.update { + it.copy( + playlist = Playlist(it.playlist.items + result.playlist.items), + ) + } } } } + private fun updateCurrentPlayback(block: (CurrentPlayback?) -> CurrentPlayback?) { + _state.update { + it.copy(currentPlayback = block.invoke(it.currentPlayback)) + } + } + /** * Play an item * @@ -436,7 +421,7 @@ class PlaybackViewModel // New item, so we can clear the media segment tracker & subtitle cues resetSegmentState() - this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) + _state.update { it.copy(subtitleCues = emptyList()) } viewModelScope.launchIO { // Starting playback, so want to invalidate the last played timestamp for this item @@ -459,7 +444,7 @@ class PlaybackViewModel // Use the provided playback parameters or else check if the database has some val itemPlayback = - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> itemPlaybackDao.getItem(user, base.id)?.let { Timber.v("Fetched itemPlayback from DB: %s", it) if (it.sourceId != null) { @@ -496,7 +481,7 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) val subtitleLanguagePreference = - serverRepository.currentUserDto.value + serverRepository.currentUserDto ?.configuration ?.subtitleLanguagePreference val subtitleStreams = @@ -572,19 +557,20 @@ class PlaybackViewModel } val chapters = Chapter.fromDto(base, api) + _state.update { + it.copy(currentItemPlayback = itemPlaybackToUse) + } + updateCurrentMedia { + CurrentMediaInfo( + sourceId = mediaSource.id, + videoStream = videoStream, + audioStreams = audioStreams, + subtitleStreams = subtitleStreams, + chapters = chapters, + trickPlayInfo = trickPlayInfo, + ) + } withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse - updateCurrentMedia { - CurrentMediaInfo( - sourceId = mediaSource.id, - videoStream = videoStream, - audioStreams = audioStreams, - subtitleStreams = subtitleStreams, - chapters = chapters, - trickPlayInfo = trickPlayInfo, - ) - } - changeStreams( item, itemPlaybackToUse, @@ -608,7 +594,7 @@ class PlaybackViewModel @OptIn(UnstableApi::class) internal suspend fun changeStreams( item: BaseItem, - currentItemPlayback: ItemPlayback = this@PlaybackViewModel.currentItemPlayback.value!!, + currentItemPlayback: ItemPlayback = state.value.currentItemPlayback!!, audioIndex: Int?, subtitleIndex: Int?, positionMs: Long = 0, @@ -618,7 +604,7 @@ class PlaybackViewModel ) = withContext(Dispatchers.IO) { val itemId = item.id - val currentPlayback = this@PlaybackViewModel.currentPlayback.value + val currentPlayback = state.value.currentPlayback if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { val wasSuccessful = changeStreamsDirectPlay( @@ -649,7 +635,7 @@ class PlaybackViewModel if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { deviceProfileService.getOrCreateDeviceProfile( preferences.appPreferences.playbackPreferences, - serverRepository.currentServer.value?.serverVersion, + serverRepository.currentServer?.serverVersion, ) } else { mpvDeviceProfile @@ -669,7 +655,7 @@ class PlaybackViewModel ), ) if (response.errorCode != null) { - loading.setValueOnMain(LoadingState.Error(response.errorCode?.serialName)) + _state.update { it.copy(loading = LoadingState.Error(response.errorCode?.serialName)) } return@withContext } val source = response.mediaSources.firstOrNull() @@ -694,9 +680,14 @@ class PlaybackViewModel source.transcodingUrl?.let(api::createUrl) } if (mediaUrl.isNullOrBlank()) { - loading.setValueOnMain( - LoadingState.Error("Unable to get media URL from the server. Do you have permission to view and/or transcode?"), - ) + _state.update { + it.copy( + loading = + LoadingState.Error( + "Unable to get media URL from the server. Do you have permission to view and/or transcode?", + ), + ) + } return@withContext } val transcodeType = @@ -794,8 +785,12 @@ class PlaybackViewModel player.addListener(activityListener) this@PlaybackViewModel.activityListener = activityListener - loading.value = LoadingState.Success - this@PlaybackViewModel.currentPlayback.update { playback } + _state.update { + it.copy( + loading = LoadingState.Success, + currentPlayback = playback, + ) + } player.setMediaItem( mediaItem, positionMs, @@ -884,19 +879,18 @@ class PlaybackViewModel viewModelScope.launchIO { Timber.v("Saving user initiated item playback: %s", itemPlayback) val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = updated - } + _state.update { it.copy(currentItemPlayback = updated) } } + } else { + _state.update { it.copy(currentItemPlayback = itemPlayback) } } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentPlayback.update { - (it ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), - ) - } - - this@PlaybackViewModel.currentItemPlayback.value = itemPlayback + _state.update { + it.copy( + currentPlayback = + (it.currentPlayback ?: currentPlayback).copy( + tracks = checkForSupport(player.currentTracks), + ), + ) } loadSubtitleDelay() return@withContext true @@ -913,14 +907,14 @@ class PlaybackViewModel val itemPlayback = itemPlaybackRepository.saveTrackSelection( item = currentItem.item, - itemPlayback = currentItemPlayback.value!!, + itemPlayback = state.value.currentItemPlayback!!, trackIndex = index, type = MediaStreamType.AUDIO, ) - this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + _state.update { it.copy(currentItemPlayback = itemPlayback) } // Resolve ONLY_FORCED to actual track based on new audio language - val source = currentPlayback.value?.mediaSourceInfo + val source = state.value.currentPlayback?.mediaSourceInfo val resolvedSubtitleIndex = if (source != null) { streamChoiceService.resolveSubtitleIndex( @@ -951,14 +945,14 @@ class PlaybackViewModel val itemPlayback = itemPlaybackRepository.saveTrackSelection( item = currentItem.item, - itemPlayback = currentItemPlayback.value!!, + itemPlayback = state.value.currentItemPlayback!!, trackIndex = index, type = MediaStreamType.SUBTITLE, ) - this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + _state.update { it.copy(currentItemPlayback = itemPlayback) } // Resolve ONLY_FORCED to actual track index for playback - val source = currentPlayback.value?.mediaSourceInfo + val source = state.value.currentPlayback?.mediaSourceInfo val resolvedIndex = if (source != null) { streamChoiceService.resolveSubtitleIndex( @@ -1004,8 +998,8 @@ class PlaybackViewModel fun getTrickplayUrl( index: Int, - trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo, - mediaSourceId: UUID? = currentItemPlayback.value?.sourceId, + trickPlayInfo: TrickplayInfo? = state.value.currentMediaInfo.trickPlayInfo, + mediaSourceId: UUID? = state.value.currentItemPlayback?.sourceId, ): String? = trickPlayInfo?.let { val itemId = currentItem.id @@ -1021,7 +1015,7 @@ class PlaybackViewModel if (playbackState == Player.STATE_ENDED) { Timber.v("Playback state is STATE_ENDED") viewModelScope.launchDefault { - when (val nextItem = playlist.value?.peek()) { + when (val nextItem = state.value.playlist.peek()) { is PlaylistItem.Intro -> { Timber.v("Next item is intro, so playing immediately") playNextUp() @@ -1033,9 +1027,7 @@ class PlaybackViewModel playNextUp() } else { Timber.v("Setting next up to ${nextItem.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem.item - } + _state.update { it.copy(nextUp = nextItem.item) } } } @@ -1060,7 +1052,7 @@ class PlaybackViewModel segmentJob?.cancel() autoSkippedSegments.clear() outroShownSegments.clear() - currentSegment.value = null + _state.update { it.copy(currentSegment = null) } } /** @@ -1086,8 +1078,7 @@ class PlaybackViewModel currentSegment.itemId == this@PlaybackViewModel.itemId ) { if (currentSegment.id != - this@PlaybackViewModel - .currentSegment.value + state.value.currentSegment ?.segment ?.id ) { @@ -1098,19 +1089,17 @@ class PlaybackViewModel currentSegment.type, ) } - val playlist = this@PlaybackViewModel.playlist.value + val playlist = state.value.playlist if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist != null && playlist.hasNext() && + playlist.hasNext() && outroShownSegments.add(currentSegment.id) ) { val nextItem = playlist.peek() if (nextItem is PlaylistItem.Media) { Timber.v("Setting next up during outro to ${nextItem?.id}") - withContext(Dispatchers.Main) { - nextUp.value = nextItem.item - } + _state.update { it.copy(nextUp = nextItem.item) } } } else { val behavior = @@ -1123,35 +1112,31 @@ class PlaybackViewModel MediaSegmentType.UNKNOWN -> SkipSegmentBehavior.IGNORE } withContext(Dispatchers.Main) { - when (behavior) { - SkipSegmentBehavior.AUTO_SKIP -> { - if (autoSkippedSegments.add(currentSegment.id)) { - onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } - } - this@PlaybackViewModel.currentSegment.update { + val newSegment = + when (behavior) { + SkipSegmentBehavior.AUTO_SKIP -> { + if (autoSkippedSegments.add(currentSegment.id)) { + onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } + } MediaSegmentState(currentSegment, true) } - } - SkipSegmentBehavior.ASK_TO_SKIP -> { - this@PlaybackViewModel.currentSegment.update { + SkipSegmentBehavior.ASK_TO_SKIP -> { MediaSegmentState( currentSegment, autoSkippedSegments.contains(currentSegment.id), ) } - } - else -> { - this@PlaybackViewModel.currentSegment.value = null + else -> { + null + } } - } + _state.update { it.copy(currentSegment = newSegment) } } } } else if (currentSegment == null) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentSegment.value = null - } + _state.update { it.copy(currentSegment = null) } } } } @@ -1163,17 +1148,13 @@ class PlaybackViewModel dismissed: Boolean, ) { viewModelScope.launchDefault { - val segment = currentSegment.value?.segment + val segment = state.value.currentSegment?.segment if (segment != null && segment.id == segmentId) { autoSkippedSegments.add(segment.id) if (dismissed) { - currentSegment.update { - it?.copy(interacted = true) - } + _state.update { it.copy(currentSegment = it.currentSegment?.copy(interacted = true)) } } else { - currentSegment.update { - null - } + _state.update { it.copy(currentSegment = null) } onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } } } @@ -1182,7 +1163,7 @@ class PlaybackViewModel private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { - currentPlayback.collectLatest { + state.map { it.currentPlayback }.collectLatest { if (it != null) { try { var transcodeInfo = it.transcodeInfo @@ -1197,13 +1178,13 @@ class PlaybackViewModel if (transcodeInfo == null) delay(3.seconds) } Timber.v("transcodeInfo=$transcodeInfo") - currentPlayback.update { current -> + updateCurrentPlayback { current -> current?.copy(transcodeInfo = transcodeInfo) } } catch (ex: Exception) { if (ex !is CancellationException) { Timber.w(ex, "Exception trying to get session info") - currentPlayback.update { current -> + updateCurrentPlayback { current -> current?.copy(transcodeInfo = null) } } @@ -1233,7 +1214,7 @@ class PlaybackViewModel } fun playNextUp() { - playlist.value?.let { + state.value.playlist.let { if (it.hasNext()) { viewModelScope.launchDefault { cancelUpNextEpisode() @@ -1248,7 +1229,7 @@ class PlaybackViewModel } fun playPrevious() { - playlist.value?.let { + state.value.playlist.let { if (it.hasPrevious()) { viewModelScope.launchDefault { cancelUpNextEpisode() @@ -1263,11 +1244,11 @@ class PlaybackViewModel } suspend fun cancelUpNextEpisode() { - nextUp.setValueOnMain(null) + _state.update { it.copy(nextUp = null) } } fun playItemInPlaylist(item: BaseItem) { - playlist.value?.let { playlist -> + state.value.playlist.let { playlist -> viewModelScope.launchIO { val toPlay = playlist.advanceTo(item.id) if (toPlay != null) { @@ -1283,7 +1264,7 @@ class PlaybackViewModel } override fun onTracksChanged(tracks: Tracks) { - currentPlayback.update { + updateCurrentPlayback { it?.copy( tracks = checkForSupport(tracks), ) @@ -1291,30 +1272,34 @@ class PlaybackViewModel } override fun onCues(cueGroup: CueGroup) { - subtitleCues.value = cueGroup.cues + _state.update { it.copy(subtitleCues = cueGroup.cues) } } override fun onPlayerError(error: PlaybackException) { Timber.e(error, "Playback error") viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { - currentPlayback.value?.let { + state.value.currentPlayback?.let { when (it.playMethod) { PlayMethod.TRANSCODE -> { - loading.setValueOnMain( - LoadingState.Error( - "Error during playback", - error, - ), - ) + _state.update { + it.copy( + loading = + LoadingState.Error( + "Error during playback", + error, + ), + ) + } } PlayMethod.DIRECT_STREAM, PlayMethod.DIRECT_PLAY -> { Timber.w("Playback error during ${it.playMethod}, falling back to transcoding") + val currentItemPlayback = state.value.currentItemPlayback!! changeStreams( currentItem.item, - currentItemPlayback.value!!, - currentItemPlayback.value?.audioIndex, - currentItemPlayback.value?.subtitleIndex, + currentItemPlayback, + currentItemPlayback.audioIndex, + currentItemPlayback.subtitleIndex, player.currentPosition, false, enableDirectPlay = false, @@ -1397,9 +1382,8 @@ class PlaybackViewModel */ internal suspend fun updateCurrentMedia(block: (CurrentMediaInfo) -> CurrentMediaInfo) = withContext(Dispatchers.IO) { - mutex.withLock { - val newMediaInfo = block.invoke(currentMediaInfo.value!!) - currentMediaInfo.setValueOnMain(newMediaInfo) + _state.update { + it.copy(currentMediaInfo = block.invoke(it.currentMediaInfo)) } } @@ -1422,7 +1406,7 @@ class PlaybackViewModel } else { decoderName } - currentPlayback.update { + updateCurrentPlayback { when (type) { MediaType.VIDEO -> it?.copy(videoDecoder = decoderString) MediaType.AUDIO -> it?.copy(audioDecoder = decoderString) @@ -1447,7 +1431,7 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("onVideoDisabled") - currentPlayback.update { it?.copy(videoDecoder = null) } + updateCurrentPlayback { it?.copy(videoDecoder = null) } } override fun onVideoInputFormatChanged( @@ -1491,21 +1475,21 @@ class PlaybackViewModel decoderCounters: DecoderCounters, ) { Timber.d("decoder: onAudioDisabled") - currentPlayback.update { it?.copy(audioDecoder = null) } + updateCurrentPlayback { it?.copy(audioDecoder = null) } } private var subtitleDelaySaveJob: Job? = null fun updateSubtitleDelay(delta: Duration) { subtitleDelaySaveJob?.cancel() - currentPlayback.update { + updateCurrentPlayback { it?.let { val newDelay = it.subtitleDelay + delta val result = it.copy(subtitleDelay = it.subtitleDelay + delta) subtitleDelaySaveJob = viewModelScope.launchIO { // Debounce & save - currentItemPlayback.value?.let { item -> + state.value.currentItemPlayback?.let { item -> delay(1500) itemPlaybackRepository.saveTrackModifications( item.itemId, @@ -1520,7 +1504,7 @@ class PlaybackViewModel } suspend fun loadSubtitleDelay() { - currentItemPlayback.value?.let { + state.value.currentItemPlayback?.let { if (it.subtitleIndexEnabled) { val result = itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex) @@ -1531,7 +1515,7 @@ class PlaybackViewModel it.subtitleIndex, it.itemId, ) - currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) } + updateCurrentPlayback { it?.copy(subtitleDelay = result.delayMs.milliseconds) } } } } @@ -1554,10 +1538,13 @@ class PlaybackViewModel bitrateEstimate, ) if (totalLoadTimeMs > 0 && totalBytesLoaded > 0) { - analyticsState.update { + _state.update { it.copy( - bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), - bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), + analyticsState = + it.analyticsState.copy( + bitrate = formatBitrate((totalBytesLoaded.toDouble() / (totalLoadTimeMs / 1000.0) * 8).roundToInt()), + bitrateEstimate = formatBitrate(bitrateEstimate.toInt()), + ), ) } } @@ -1568,24 +1555,13 @@ class PlaybackViewModel droppedFrames: Int, elapsedMs: Long, ) { -// Timber.v("onDroppedVideoFrames: droppedFrames=%s", droppedFrames) - analyticsState.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) } + _state.update { + it.copy( + analyticsState = + it.analyticsState.copy( + droppedFrames = it.analyticsState.droppedFrames + droppedFrames, + ), + ) + } } } - -data class PlayerState( - val player: Player, - val backend: PlayerBackend, - val assHandler: AssHandler?, -) - -data class MediaSegmentState( - val segment: MediaSegmentDto, - val interacted: Boolean, -) - -data class AnalyticsState( - val bitrate: String = formatBitrate(0), - val bitrateEstimate: String = formatBitrate(0), - val droppedFrames: Int = 0, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt new file mode 100644 index 00000000..5b00aacc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayerState.kt @@ -0,0 +1,42 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.media3.common.Player +import androidx.media3.common.listenTo + +/** + * Remembers the [Player]'s state as it changes. Useful for changing UI if the player is buffering. + * + * @see Player.State + * @see PlayerState + */ +@Composable +fun rememberPlayerState(player: Player): State { + val state = remember(player) { mutableStateOf(getPlayerState(player.playbackState)) } + LaunchedEffect(player) { + player.listenTo(Player.EVENT_PLAYBACK_STATE_CHANGED) { + state.value = getPlayerState(player.playbackState) + } + } + return state +} + +private fun getPlayerState( + @Player.State value: Int, +): PlayerState = PlayerState.entries.first { it.value == value } + +/** + * Represents [Player.State] integers as an Enum + */ +enum class PlayerState( + @param:Player.State val value: Int, +) { + IDLE(Player.STATE_IDLE), + BUFFERING(Player.STATE_BUFFERING), + READY(Player.STATE_READY), + ENDED(Player.STATE_ENDED), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 571d5210..5892b01a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -9,10 +9,10 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -22,6 +22,8 @@ import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber sealed interface SubtitleSearchStatus { + data object Inactive : SubtitleSearchStatus + data object Searching : SubtitleSearchStatus data object Downloading : SubtitleSearchStatus @@ -40,11 +42,15 @@ sealed interface SubtitleSearchStatus { * Trigger a search for subtitles in the given language for the currently playing media */ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.language) { - subtitleSearchStatus.value = SubtitleSearchStatus.Searching - subtitleSearchLanguage.value = language + subtitleSearchState.update { + it.copy( + status = SubtitleSearchStatus.Searching, + language = language, + ) + } viewModelScope.launchIO { try { - currentItemPlayback.value?.itemId?.let { + state.value.currentItemPlayback?.itemId?.let { Timber.v("Searching for remote subtitles for %s", it) val results = api.subtitleApi @@ -56,11 +62,11 @@ fun PlaybackViewModel.searchForSubtitles(language: String = Locale.current.langu compareByDescending { it.communityRating } .thenByDescending { it.downloadCount }, ) - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Success(results)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Success(results)) } } } catch (ex: Exception) { Timber.e(ex, "Exception while searching for subtitles") - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Error(null, ex)) } } } } @@ -73,12 +79,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( wasPlaying: Boolean, ) { if (subtitleId == null) { - subtitleSearchStatus.value = SubtitleSearchStatus.Error("Subtitle has no ID", null) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + "Subtitle has no ID", + null, + ), + ) + } } else { - subtitleSearchStatus.value = SubtitleSearchStatus.Downloading + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Downloading) } viewModelScope.launchIO { try { - currentItemPlayback.value?.let { + state.value.currentItemPlayback?.let { Timber.v( "Downloading remote subtitles for itemId=%s, sourceId=%s: %s", it.itemId, @@ -89,8 +103,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) - val currentSource = - this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo + val currentSource = state.value.currentPlayback?.mediaSourceInfo val currentSubtitleStreams = currentSource ?.mediaStreams @@ -144,7 +157,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( stream.isExternal && stream.path !in externalPaths } if (newStream != null) { - var audioIndex = currentItemPlayback.value?.audioIndex + var audioIndex = it?.audioIndex if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { // User has picked a specific audio track // Since, now adding a new external subtitle track, need to adjust the audio index as well @@ -161,7 +174,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } this@downloadAndSwitchSubtitles.changeStreams( currentItem.item, - currentItemPlayback.value!!, + it, audioIndex, newStream.index, onMain { player.currentPosition }, @@ -169,7 +182,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } } - subtitleSearchStatus.setValueOnMain(null) + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } withContext(Dispatchers.Main) { if (wasPlaying) { player.play() @@ -178,12 +191,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( } } catch (ex: Exception) { Timber.e(ex, "Exception while downloading subtitles: $subtitleId") - subtitleSearchStatus.setValueOnMain(SubtitleSearchStatus.Error(null, ex)) + subtitleSearchState.update { + it.copy( + status = + SubtitleSearchStatus.Error( + null, + ex, + ), + ) + } } } } } fun PlaybackViewModel.cancelSubtitleSearch() { - subtitleSearchStatus.value = null + subtitleSearchState.update { it.copy(status = SubtitleSearchStatus.Inactive) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt index 0ac88d55..a3e50a85 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/LocaleChoiceDialog.kt @@ -61,7 +61,7 @@ class LocaleChoiceViewModel init { viewModelScope.launchDefault { - serverRepository.currentUser.value?.let { + serverRepository.currentUser?.let { val availableLocales = extractAvailableLocales() Timber.v("availableLocales=%s", availableLocales) _state.update { @@ -73,7 +73,7 @@ class LocaleChoiceViewModel } } viewModelScope.launchDefault { - serverRepository.currentUser.asFlow().collectLatest { user -> + serverRepository.currentUserFlow.collectLatest { user -> val userLocale = user?.uiLanguage?.let { Locale.forLanguageTag(it) } ?: Locale.getDefault() _state.update { it.copy(userLocale = userLocale) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index 89fce8fb..bd8a95be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -297,7 +297,7 @@ class NavDrawerPreferencesViewModel init { viewModelScope.launchDefault { val state = navDrawerService.state.value - val user = serverRepository.currentUser.value + val user = serverRepository.currentUser val seerr = seerrServerRepository.active.firstOrNull() if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { return@launchDefault @@ -337,8 +337,8 @@ class NavDrawerPreferencesViewModel fun save() { viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - serverRepository.currentUserDto.value?.let { userDto -> + serverRepository.currentUser?.let { user -> + serverRepository.currentUserDto?.let { userDto -> if (user.id == userDto.id) { val toSave = state.value.mapIndexed { index, item -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 67b5733e..982e2537 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -100,7 +99,7 @@ fun PreferencesContent( var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } - val currentUser by viewModel.currentUser.observeAsState() + val currentUser by viewModel.currentUser.collectAsState() val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } @@ -126,16 +125,20 @@ fun PreferencesContent( updateCache = false } - val release by updateVM.release.observeAsState(null) - LaunchedEffect(Unit) { + val updateState by updateVM.state.collectAsState() + val release = updateState.release + LaunchedEffect(preferences.updateUrl, preferences.autoCheckForUpdates) { if (UpdateChecker.ACTIVE && preferences.autoCheckForUpdates) { - updateVM.init(preferences.updateUrl) + updateVM.init() } } val movementSounds = true val installedVersion = updateVM.currentVersion - val updateAvailable = release?.version?.isGreaterThan(installedVersion) ?: false + val updateAvailable = + remember(updateState.release) { + updateState.release?.version?.isGreaterThan(installedVersion) ?: false + } val prefList = when (preferenceScreenOption) { @@ -306,7 +309,7 @@ fun PreferencesContent( ) } } else { - updateVM.init(preferences.updateUrl) + updateVM.init() } }, onLongClick = { @@ -635,7 +638,7 @@ fun PreferencesContent( } SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.observeAsState() + val currentUser by seerrVm.currentUser.collectAsState(null) val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) val serverAddedMessage = stringResource(R.string.seerr_server_added) LaunchedEffect(status) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index a55ddf9e..718febf3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -32,7 +32,9 @@ import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo @@ -58,7 +60,13 @@ class PreferencesViewModel private val updateChecker: UpdateChecker, ) : ViewModel(), RememberTabManager by rememberTabManager { - val currentUser get() = serverRepository.currentUser + val currentUser + get() = + serverRepository.currentUserFlow.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null, + ) val seerrConnection = seerrServerRepository.connection diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt index 7e5a6ec5..a3bd52e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/InstallUpdatePage.kt @@ -21,9 +21,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -39,7 +38,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme @@ -51,6 +49,8 @@ import com.github.damontecres.wholphin.services.DownloadCallback import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -58,47 +58,56 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.formatBytes -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.theme.WholphinTheme 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.util.Version import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.markdownTypography import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject @HiltViewModel class UpdateViewModel @Inject constructor( + private val userPreferencesService: UserPreferencesService, val updater: UpdateChecker, val navigationManager: NavigationManager, ) : ViewModel(), DownloadCallback { - val loading = MutableLiveData(LoadingState.Pending) - val release = MutableLiveData(null) + private val _state = MutableStateFlow(InstallUpdateState()) + val state: StateFlow = _state - val downloading = MutableLiveData(false) - val contentLength = MutableLiveData(-1) - val bytesDownloaded = MutableLiveData(-1) + private val _bytesDownloaded = MutableStateFlow(0L) + val bytesDownloaded: StateFlow = _bytesDownloaded val currentVersion = updater.getInstalledVersion() - fun init(updateUrl: String) { - loading.value = LoadingState.Loading - viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to check for update")) { - val release = updater.getLatestRelease(updateUrl) - withContext(Dispatchers.Main) { - contentLength.value = -1 - bytesDownloaded.value = -1 - this@UpdateViewModel.release.value = release - loading.value = LoadingState.Success + fun init() { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val updateUrl = userPreferencesService.getCurrent().appPreferences.updateUrl + try { + val release = updater.getLatestRelease(updateUrl) + _state.update { + it.copy( + loading = LoadingState.Success, + release = release, + contentLength = -1L, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching release from %s", updateUrl) + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } @@ -107,61 +116,59 @@ class UpdateViewModel fun installRelease(release: Release) { downloadJob = - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Failed to install update", - ), - ) { - downloading.setValueOnMain(true) - updater.installRelease(release, this@UpdateViewModel) - downloading.setValueOnMain(false) + viewModelScope.launchIO { + try { + _bytesDownloaded.value = 0L + _state.update { it.copy(downloading = true) } + updater.installRelease(release, this@UpdateViewModel) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error downloading") + _state.update { it.copy(loading = LoadingState.Error(ex)) } + } finally { + _state.update { it.copy(downloading = false) } + } } } fun cancelDownload() { - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error", - ), - ) { + viewModelScope.launchIO { downloadJob?.cancel() - withContext(Dispatchers.Main) { - downloading.value = false - contentLength.value = -1 - bytesDownloaded.value = -1 + _state.update { + it.copy( + downloading = false, + contentLength = -1L, + ) } } } override fun contentLength(contentLength: Long) { - this@UpdateViewModel.contentLength.value = contentLength + _state.update { it.copy(contentLength = contentLength) } } override fun bytesDownloaded(bytes: Long) { - this@UpdateViewModel.bytesDownloaded.value = bytes + _bytesDownloaded.value = bytes } } +data class InstallUpdateState( + val loading: LoadingState = LoadingState.Pending, + val downloading: Boolean = false, + val release: Release? = null, + val contentLength: Long = -1L, +) + @Composable fun InstallUpdatePage( preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: UpdateViewModel = hiltViewModel(), ) { - val loading by viewModel.loading.observeAsState(LoadingState.Pending) - val release by viewModel.release.observeAsState(null) + OneTimeLaunchedEffect { viewModel.init() } - val isDownloading by viewModel.downloading.observeAsState(false) - val contentLength by viewModel.contentLength.observeAsState(-1L) - val bytesDownloaded by viewModel.bytesDownloaded.observeAsState(-1) - - LaunchedEffect(Unit) { - viewModel.init(preferences.appPreferences.updateUrl) - } + val state by viewModel.state.collectAsState() var permissions by remember { mutableStateOf(viewModel.updater.hasPermissions()) } val launcher = rememberLauncherForActivityResult( @@ -173,9 +180,9 @@ fun InstallUpdatePage( // TODO } } - when (val state = loading) { + when (val st = state.loading) { is LoadingState.Error -> { - ErrorMessage(state, modifier) + ErrorMessage(st, modifier) } LoadingState.Loading, @@ -185,26 +192,32 @@ fun InstallUpdatePage( } LoadingState.Success -> { - release?.let { + val release = state.release + if (release != null) { InstallUpdatePageContent( currentVersion = viewModel.currentVersion, - release = it, + release = release, onInstallRelease = { if (!permissions) { launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { - viewModel.installRelease(it) + viewModel.installRelease(release) } }, onCancel = { viewModel.navigationManager.goBack() }, - modifier = modifier.dimAndBlur(isDownloading), + modifier = modifier.dimAndBlur(state.downloading), + ) + } else { + Text( + text = "No release found! Check the update URL.", ) } - if (isDownloading) { + if (state.downloading) { + val bytesDownloaded by viewModel.bytesDownloaded.collectAsState(0L) DownloadDialog( - contentLength = contentLength, + contentLength = state.contentLength, bytesDownloaded = bytesDownloaded, onDismissRequest = { viewModel.cancelDownload() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 13ea2b86..2d9158de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -70,7 +69,7 @@ fun SwitchUserContent( val state by viewModel.state.collectAsState() - val currentUser by viewModel.serverRepository.currentUser.observeAsState() + val currentUser by viewModel.serverRepository.currentUserFlow.collectAsState(null) var showAddUser by remember { mutableStateOf(false) } var addUser by remember(server) { mutableStateOf(null) } var username by remember(addUser) { mutableStateOf(addUser?.name ?: "") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 7c05194a..773f6c74 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -29,7 +29,7 @@ class SwitchSeerrViewModel private val seerrService: SeerrService, private val serverRepository: ServerRepository, ) : ViewModel() { - val currentUser = serverRepository.currentUser + val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index faccb4cc..9a988753 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,7 +58,6 @@ import coil3.request.ImageRequest import coil3.request.transitionFactory import coil3.size.Size import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -93,9 +91,9 @@ fun SlideshowPage( ), ) { val context = LocalContext.current - val loading by viewModel.loading.collectAsState() + val state by viewModel.state.collectAsState() - when (val st = loading) { + when (val st = state.loading) { is LoadingState.Error -> { ErrorMessage(st, modifier) } @@ -107,10 +105,10 @@ fun SlideshowPage( } LoadingState.Success -> { - val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) - val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) - val position by viewModel.position.observeAsState(0) - val pager by viewModel.pager.observeAsState() + val loadingState = state.imageLoading + val imageFilter by viewModel.imageFilter.collectAsState() + val position = state.position + val pager = state.items // val imageState by viewModel.image.observeAsState() var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } @@ -150,7 +148,7 @@ fun SlideshowPage( label = "image_panY", ) - val slideshowState by viewModel.slideshow.collectAsState() + val slideshowState by viewModel.state.collectAsState() val slideshowActive by viewModel.slideshowActive.collectAsState(false) val focusRequester = remember { FocusRequester() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index f9abe15d..9744c9de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -3,10 +3,7 @@ package com.github.damontecres.wholphin.ui.slideshow import android.content.Context import android.widget.Toast import androidx.compose.runtime.Stable -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import androidx.media3.common.Player import com.github.damontecres.wholphin.data.ChosenStreams @@ -25,9 +22,7 @@ import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.ui.util.ThrottledLiveData import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -87,28 +82,18 @@ class SlideshowViewModel /** * Whether slideshow mode is on or off */ - private val _slideshow = MutableStateFlow(SlideshowState(false, false)) - val slideshow: StateFlow = _slideshow + private val _state = MutableStateFlow(SlideshowState()) + val state: StateFlow = _state /** * Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused */ - val slideshowActive = slideshow.map { it.enabled && !it.paused } + val slideshowActive = state.map { it.enabled && !it.paused } var slideshowDelay by Delegates.notNull() - private val _pager = MutableLiveData>() - val pager: LiveData> = _pager.map { it } - val position = MutableLiveData(0) - - private val _image = MutableLiveData() - val image: LiveData = _image - - val loading = MutableStateFlow(LoadingState.Pending) - - val loadingState = MutableLiveData(ImageLoadingState.Loading) - private val _imageFilter = MutableLiveData(VideoFilter()) - val imageFilter = ThrottledLiveData(_imageFilter, 500L) + private val _imageFilter = MutableStateFlow(VideoFilter()) + val imageFilter: StateFlow = _imageFilter private var albumImageFilter = VideoFilter() @@ -152,7 +137,7 @@ class SlideshowViewModel sortOrder = listOf(slideshowSettings.sortAndDirection.direction), ), ) - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val filter = playbackEffectDao .getPlaybackEffect( @@ -168,21 +153,25 @@ class SlideshowViewModel val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) .init(slideshowSettings.index) - this@SlideshowViewModel._pager.setValueOnMain(pager) - loading.update { LoadingState.Success } + _state.update { + it.copy( + loading = LoadingState.Success, + items = pager, + ) + } updatePosition(slideshowSettings.index)?.join() if (slideshowSettings.startSlideshow) onMain { startSlideshow() } } catch (ex: Exception) { Timber.e(ex, "Error") - loading.update { LoadingState.Error(ex) } + _state.update { it.copy(loading = LoadingState.Error(ex)) } } } } fun nextImage(): Boolean { - val size = pager.value?.size - val newPosition = position.value!! + 1 - return if (size != null && newPosition < size) { + val size = state.value.items.size + val newPosition = state.value.position + 1 + return if (newPosition < size) { updatePosition(newPosition) true } else { @@ -191,7 +180,7 @@ class SlideshowViewModel } fun previousImage(): Boolean { - val newPosition = position.value!! - 1 + val newPosition = state.value.position - 1 return if (newPosition >= 0) { updatePosition(newPosition) true @@ -201,14 +190,14 @@ class SlideshowViewModel } fun updatePosition(position: Int): Job? = - _pager.value?.let { pager -> + (state.value.items as? ApiRequestPager<*>)?.let { pager -> viewModelScope.launchIO { try { val image = if (position in pager.indices) pager.getBlocking(position) else null Timber.v("Got image for $position: ${image != null}") if (image != null) { - this@SlideshowViewModel.position.setValueOnMain(position) + _state.update { it.copy(position = position) } val url = if (image.data.mediaType == MediaType.VIDEO) { @@ -252,7 +241,7 @@ class SlideshowViewModel updateImageFilter(albumImageFilter) if (saveFilters) { viewModelScope.launchIO { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> val vf = playbackEffectDao .getPlaybackEffect( @@ -264,32 +253,38 @@ class SlideshowViewModel Timber.d( "Loaded VideoFilter for image ${image.id}", ) - withContext(Dispatchers.Main) { - // Pause throttling so that the image loads with the filter applied immediately - imageFilter.stopThrottling(true) - updateImageFilter(vf.videoFilter) - imageFilter.startThrottling() - } + updateImageFilter(vf.videoFilter) } - withContext(Dispatchers.Main) { - _image.value = imageState - loadingState.value = - ImageLoadingState.Success(imageState) + _state.update { + it.copy( + image = imageState, + imageLoading = ImageLoadingState.Success(imageState), + ) } } } } else { - withContext(Dispatchers.Main) { - _image.value = imageState - loadingState.value = ImageLoadingState.Success(imageState) + _state.update { + it.copy( + image = imageState, + imageLoading = ImageLoadingState.Success(imageState), + ) } } } else { - loadingState.setValueOnMain(ImageLoadingState.Error) + _state.update { + it.copy( + imageLoading = ImageLoadingState.Error, + ) + } } } catch (ex: Exception) { Timber.e(ex) - loadingState.setValueOnMain(ImageLoadingState.Error) + _state.update { + it.copy( + imageLoading = ImageLoadingState.Error, + ) + } } } } @@ -298,10 +293,10 @@ class SlideshowViewModel fun startSlideshow() { screensaverService.keepScreenOn(true) - _slideshow.update { - SlideshowState(enabled = true, paused = false) + _state.update { + it.copy(enabled = true, paused = false) } - if (_image.value + if (state.value.image ?.image ?.data ?.mediaType != MediaType.VIDEO @@ -313,14 +308,14 @@ class SlideshowViewModel fun stopSlideshow() { screensaverService.keepScreenOn(false) slideshowJob?.cancel() - _slideshow.update { - SlideshowState(enabled = false, paused = false) + _state.update { + it.copy(enabled = false, paused = false) } } fun pauseSlideshow() { Timber.v("pauseSlideshow") - _slideshow.update { + _state.update { if (it.enabled) { slideshowJob?.cancel() it.copy(paused = true) @@ -332,7 +327,7 @@ class SlideshowViewModel fun unpauseSlideshow() { Timber.v("unpauseSlideshow") - _slideshow.update { + _state.update { if (it.enabled) { it.copy(paused = false) } else { @@ -362,16 +357,16 @@ class SlideshowViewModel fun updateImageFilter(newFilter: VideoFilter) { viewModelScope.launchIO { - _imageFilter.setValueOnMain(newFilter) + _imageFilter.update { newFilter } } } fun saveImageFilter() { - image.value?.let { + state.value.image?.let { viewModelScope.launchIO { val vf = _imageFilter.value if (vf != null) { - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> playbackEffectDao .insert( PlaybackEffect( @@ -400,7 +395,7 @@ class SlideshowViewModel val vf = _imageFilter.value if (vf != null) { albumImageFilter = vf - serverRepository.currentUser.value?.let { user -> + serverRepository.currentUser?.let { user -> playbackEffectDao .insert( PlaybackEffect( @@ -457,6 +452,11 @@ data class ImageState( } data class SlideshowState( - val enabled: Boolean, - val paused: Boolean, + val items: List = emptyList(), + val position: Int = 0, + val image: ImageState? = null, + val loading: LoadingState = LoadingState.Pending, + val imageLoading: ImageLoadingState = ImageLoadingState.Loading, + val enabled: Boolean = false, + val paused: Boolean = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt deleted file mode 100644 index 5a27f818..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.github.damontecres.wholphin.ui.util - -import android.os.Handler -import android.os.Looper -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData - -/** - * LiveData throttling value emissions so they don't happen more often than [delayMs]. - * - * From https://stackoverflow.com/a/62467521 - */ -class ThrottledLiveData( - source: LiveData, - delayMs: Long, -) : MediatorLiveData() { - val handler = Handler(Looper.getMainLooper()) - var delayMs = delayMs - private set - - private var isValueDelayed = false - private var delayedValue: T? = null - private var delayRunnable: Runnable? = null - set(value) { - field?.let { handler.removeCallbacks(it) } - value?.let { handler.postDelayed(it, delayMs) } - field = value - } - private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() } - - init { - addSource(source) { newValue -> - if (delayRunnable == null) { - value = newValue - startDelay() - } else { - isValueDelayed = true - delayedValue = newValue - } - } - } - - /** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */ - fun startThrottling(newDelay: Long = 0L) { - require(newDelay >= 0L) - when { - newDelay > 0 -> delayMs = newDelay - delayMs < 0 -> delayMs *= -1 - delayMs > 0 -> return - else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero") - } - } - - /** Stop throttling, if [immediate] emit any pending value now. */ - fun stopThrottling(immediate: Boolean = false) { - if (delayMs <= 0) return - delayMs *= -1 - if (immediate) consumeDelayedValue() - } - - override fun onInactive() { - super.onInactive() - consumeDelayedValue() - } - - // start counting the delay or clear it if conditions are not met - private fun startDelay() { - delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null - } - - private fun consumeDelayedValue(): Boolean { - delayRunnable = null - return if (isValueDelayed) { - value = delayedValue - delayedValue = null - isValueDelayed = false - true - } else { - false - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt deleted file mode 100644 index 7ee4d0f8..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/EqualityMutableLiveData.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.damontecres.wholphin.util - -import androidx.lifecycle.MutableLiveData - -/** - * A [MutableLiveData] that only notifies observers if the value does not equal the previous value - */ -class EqualityMutableLiveData : MutableLiveData { - constructor() : super() - constructor(value: T) : super(value) - - override fun setValue(value: T?) { - if (value != getValue()) { - super.setValue(value) - } - } - - fun setValueNoCheck(value: T?) { - super.setValue(value) - } - - override fun postValue(value: T?) { - if (value != getValue()) { - super.postValue(value) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt deleted file mode 100644 index 3e9c8ea9..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingExceptionHandler.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.github.damontecres.wholphin.util - -import android.widget.Toast -import androidx.lifecycle.MutableLiveData -import com.github.damontecres.wholphin.WholphinApplication -import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import timber.log.Timber -import kotlin.coroutines.CoroutineContext - -/** - * A [CoroutineExceptionHandler] that is aware of a [LoadingState] and will set it to error if an exception occurs in the coroutine. - */ -class LoadingExceptionHandler( - private val loadingState: MutableLiveData, - private val errorMessage: String?, - private val autoToast: Boolean = false, -) : CoroutineExceptionHandler { - override val key: CoroutineContext.Key<*> - get() = CoroutineExceptionHandler - - override fun handleException( - context: CoroutineContext, - exception: Throwable, - ) { - if (exception is CancellationException) { - // Don't log/toast cancellations - return - } - Timber.e(exception, "Exception in coroutine") - runBlocking { - withContext(Dispatchers.Main) { - loadingState.value = - LoadingState.Error( - message = errorMessage, - exception = exception, - ) - } - if (autoToast) { - showToast( - WholphinApplication.instance, - "Error: ${exception.message}", - Toast.LENGTH_LONG, - ) - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 585fff4b..f42eb6ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -103,3 +103,5 @@ sealed interface DataLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +val DataLoadingState.successValue: T? get() = (this as? DataLoadingState.Success)?.data diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt index 2f30db77..d4f4d436 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpSchedulerServiceTest.kt @@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.MutableLiveData import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository @@ -14,6 +13,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -28,7 +28,7 @@ import org.junit.Test class LatestNextUpSchedulerServiceTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() private val testDispatcher = StandardTestDispatcher() - private val currentLiveData = MutableLiveData() + private val currentUser = MutableStateFlow(null) private val mockActivity = mockk(relaxed = true) private val mockServerRepository = mockk(relaxed = true) private val mockWorkManager = mockk(relaxed = true) @@ -38,7 +38,7 @@ class LatestNextUpSchedulerServiceTest { fun setUp() { Dispatchers.setMain(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry - every { mockServerRepository.current } returns currentLiveData + every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @@ -59,7 +59,7 @@ class LatestNextUpSchedulerServiceTest { runTest { createService() - currentLiveData.value = null + currentUser.value = null advanceUntilIdle() verify { mockWorkManager.cancelUniqueWork(LatestNextUpWorker.WORK_NAME) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt index 57e14ec1..436fd360 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.services import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo @@ -15,6 +14,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher @@ -98,8 +98,8 @@ class SuggestionServiceTest { @Test fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = runTest { - val currentUser = MutableLiveData(null) - every { mockServerRepository.currentUser } returns currentUser + val currentUser = MutableStateFlow(null) + every { mockServerRepository.currentUserFlow } returns currentUser val service = createService() val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() @@ -113,10 +113,10 @@ class SuggestionServiceTest { listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null mockEnqueueOnDemandWork() every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns @@ -142,10 +142,10 @@ class SuggestionServiceTest { listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state -> val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null mockEnqueueOnDemandWork() every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns @@ -163,10 +163,10 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null mockEnqueueOnDemandWork() every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) @@ -189,11 +189,11 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) val cachedId = UUID.randomUUID() - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returnsMany listOf(null, CachedSuggestions(listOf(cachedId))) mockEnqueueOnDemandWork() @@ -220,9 +220,9 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) val service = createService() @@ -244,10 +244,10 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, parentId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null mockEnqueueOnDemandWork() every { mockWorkManager.getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) } returns @@ -268,10 +268,10 @@ class SuggestionServiceTest { val userId = UUID.randomUUID() val libraryId = UUID.randomUUID() val otherLibraryId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) val workName = SuggestionsWorker.getOnDemandWorkName(userId, libraryId, BaseItemKind.MOVIE) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser mockEnqueueOnDemandWork() every { mockWorkManager.getWorkInfosForUniqueWorkFlow(workName) } returns flowOf(emptyList()) @@ -304,9 +304,9 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser val cachedId = UUID.randomUUID() coEvery { @@ -339,9 +339,9 @@ class SuggestionServiceTest { runTest { val userId = UUID.randomUUID() val parentId = UUID.randomUUID() - val currentUser = MutableLiveData(mockUser(userId)) + val currentUser = MutableStateFlow(mockUser(userId)) - every { mockServerRepository.currentUser } returns currentUser + every { mockServerRepository.currentUserFlow } returns currentUser val cachedId = UUID.randomUUID() coEvery { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 0b48a011..0d52db15 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -5,7 +5,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.MutableLiveData import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser @@ -19,6 +18,7 @@ import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -35,7 +35,7 @@ import java.util.UUID class SuggestionsSchedulerServiceTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() private val testDispatcher = StandardTestDispatcher() - private val currentLiveData = MutableLiveData() + private val currentUser = MutableStateFlow(null) private val mockActivity = mockk(relaxed = true) private val mockServerRepository = mockk(relaxed = true) private val mockWorkManager = mockk(relaxed = true) @@ -45,7 +45,7 @@ class SuggestionsSchedulerServiceTest { fun setUp() { Dispatchers.setMain(testDispatcher) every { mockActivity.lifecycle } returns lifecycleRegistry - every { mockServerRepository.current } returns currentLiveData + every { mockServerRepository.current } returns currentUser lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) } @@ -57,8 +57,8 @@ class SuggestionsSchedulerServiceTest { context = mockActivity, serverRepository = mockServerRepository, workManager = mockWorkManager, + dispatcher = testDispatcher, ).also { - it.dispatcher = testDispatcher it.initialDelaySecondsProvider = { 60L } } @@ -74,7 +74,7 @@ class SuggestionsSchedulerServiceTest { runTest { mockWorkInfos(emptyList()) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -88,13 +88,13 @@ class SuggestionsSchedulerServiceTest { runTest { mockWorkInfos(emptyList()) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), ) advanceUntilIdle() - currentLiveData.value = null + currentUser.value = null advanceUntilIdle() verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } } @@ -113,7 +113,7 @@ class SuggestionsSchedulerServiceTest { } returns mockk() createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -138,7 +138,7 @@ class SuggestionsSchedulerServiceTest { } returns mockk() createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), @@ -159,7 +159,7 @@ class SuggestionsSchedulerServiceTest { mockWorkInfos(listOf(workInfo)) createService() - currentLiveData.value = + currentUser.value = CurrentUser( user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 4b5a9450..e066e837 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -1,7 +1,6 @@ package com.github.damontecres.wholphin.services import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.WorkerParameters @@ -15,6 +14,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.api.client.ApiClient @@ -112,7 +112,7 @@ class SuggestionsWorkerTest { every { mockApi.accessToken } returns null val mockUser = mockk() var restored = false - every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) } + every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { restored = true mockUser diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index f4d04c7e..dbf04447 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.test -import androidx.lifecycle.MutableLiveData import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback @@ -773,20 +772,19 @@ private fun serverRepo( ): ServerRepository { val mocked = mockk() every { mocked.currentUserDto } returns - MutableLiveData( - UserDto( - id = UUID.randomUUID(), - hasPassword = true, - hasConfiguredPassword = true, - hasConfiguredEasyPassword = true, - configuration = - DefaultUserConfiguration.copy( - audioLanguagePreference = audioLang, - subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, - subtitleLanguagePreference = subtitleLang, - ), - ), + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = true, + configuration = + DefaultUserConfiguration.copy( + audioLanguagePreference = audioLang, + subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, + subtitleLanguagePreference = subtitleLang, + ), ) + return mocked } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 6ebdde41..8e43a4b0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -108,7 +108,7 @@ object TestModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.currentUser.value?.accessToken?.let { token -> + serverRepository.currentUser?.accessToken?.let { token -> request .newBuilder() .addHeader( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 261f2dfc..3d0d64ad 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,14 +67,12 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } -androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } -androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }