From 5e2c75bb700450ec12eeed6bc745a58c8e31df0c Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 11 Nov 2025 20:10:16 -0500 Subject: [PATCH 1/5] Log while navigating around the app (#195) Just adds some logging while navigating around the app. If an error or crash occurs and users want to share their logs, this information is useful to know what page. Logs are never sent without explicit user content and they are only shared with the Jellyfin server the app is currently connected to. [See here for more details](https://github.com/damontecres/Wholphin/wiki/Gathering-app-logs#privacy) Also stops theme music when the app is paused similar to #191 --- .../github/damontecres/wholphin/ui/Extensions.kt | 10 ++++++++++ .../wholphin/ui/detail/CollectionFolderLiveTv.kt | 2 ++ .../wholphin/ui/detail/CollectionFolderMovie.kt | 2 ++ .../wholphin/ui/detail/CollectionFolderTv.kt | 2 ++ .../damontecres/wholphin/ui/detail/FavoritesPage.kt | 2 ++ .../ui/detail/series/SeriesOverviewContent.kt | 5 +++++ .../wholphin/ui/nav/ApplicationContent.kt | 2 -- .../wholphin/ui/nav/NavigationManager.kt | 13 +++++++++++++ .../wholphin/util/PlaybackLifecycleObserver.kt | 3 +++ .../damontecres/wholphin/util/ThemeSongPlayer.kt | 2 +- 10 files changed, 40 insertions(+), 3 deletions(-) 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 e8f6071f..b137b439 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 @@ -38,6 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.acra.ACRA import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -380,3 +381,12 @@ fun equalsNotNull( a: Any?, b: Any?, ) = a != null && b != null && a == b + +fun logTab( + name: String, + tabIndex: Int, +) { + val info = "$tabIndex, $name" + Timber.i("Current tab: $info") + ACRA.errorReporter.putCustomData("tabInfo", info) +} 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 66dd2779..fdf369f2 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 @@ -38,6 +38,7 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.detail.livetv.DvrSchedule 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.nav.NavigationManager import com.github.damontecres.wholphin.ui.setValueOnMain @@ -102,6 +103,7 @@ fun CollectionFolderLiveTv( LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { + logTab("livetv", selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) } val onClickItem = { item: BaseItem -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 7cef5122..5c156e28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.ui.components.RecommendedMovie import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -62,6 +63,7 @@ fun CollectionFolderMovie( LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { + logTab("movie", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 8155fbc5..3170a0af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.RecommendedTvShow import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.SeriesSortOptions +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -60,6 +61,7 @@ fun CollectionFolderTv( LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { + logTab("tv", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index 002ca4ef..bae04bcb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.data.EpisodeSortOptions import com.github.damontecres.wholphin.ui.data.MovieSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -63,6 +64,7 @@ fun FavoritesPage( val focusRequester = remember { FocusRequester() } LaunchedEffect(selectedTabIndex) { + logTab("favorites", selectedTabIndex) preferencesViewModel.saveRememberedTab( preferences, NavDrawerItem.Favorites.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index ce73e872..cc326b24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf @@ -50,6 +51,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.formatDateTime import kotlin.time.Duration @@ -77,6 +79,9 @@ fun SeriesOverviewContent( ) { val bringIntoViewRequester = remember { BringIntoViewRequester() } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } + LaunchedEffect(selectedTabIndex) { + logTab("series_overview", selectedTabIndex) + } val tabRowFocusRequester = remember { FocusRequester() } val focusedEpisode = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 14476e70..e1aff5ae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.UserPreferences import org.jellyfin.sdk.model.api.DeviceProfile -import timber.log.Timber /** * This is generally the root composable of the of the app @@ -38,7 +37,6 @@ fun ApplicationContent( entryProvider = { key -> key as Destination val contentKey = "${key}_${server?.id}_${user?.id}" - Timber.d("Navigate: %s", key) NavEntry(key, contentKey = contentKey) { if (key.fullScreen) { DestinationContent( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt index c41fd16e..8a9b50a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.nav import androidx.navigation3.runtime.NavKey +import org.acra.ACRA +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +20,7 @@ class NavigationManager */ fun navigateTo(destination: Destination) { backStack.add(destination) + log() } /** @@ -26,6 +29,7 @@ class NavigationManager fun navigateToFromDrawer(destination: Destination) { goToHome() backStack.add(destination) + log() } /** @@ -33,6 +37,7 @@ class NavigationManager */ fun goBack() { backStack.removeLastOrNull() + log() } /** @@ -45,6 +50,7 @@ class NavigationManager if (backStack[0] !is Destination.Home) { backStack[0] = Destination.Home() } + log() } /** @@ -54,5 +60,12 @@ class NavigationManager goToHome() val id = (backStack[0] as Destination.Home).id + 1 backStack[0] = Destination.Home(id) + log() + } + + private fun log() { + val dest = backStack.lastOrNull().toString() + Timber.i("Current Destination: %s", dest) + ACRA.errorReporter.putCustomData("destination", dest) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt index fcc49917..be93c292 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/PlaybackLifecycleObserver.kt @@ -16,6 +16,7 @@ class PlaybackLifecycleObserver constructor( private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, + private val themeSongPlayer: ThemeSongPlayer, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null @@ -36,11 +37,13 @@ class PlaybackLifecycleObserver wasPlaying = it.isPlaying it.pause() } + themeSongPlayer.stop() } override fun onStop(owner: LifecycleOwner) { if (navigationManager.backStack.lastOrNull() is Destination.Playback) { navigationManager.goBack() } + themeSongPlayer.stop() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt index 1327b6f2..bb9ec887 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ThemeSongPlayer.kt @@ -78,7 +78,6 @@ class ThemeSongPlayer volumeLevel: ThemeSongVolume, url: String, ) { - stop() val volumeLevel = when (volumeLevel) { ThemeSongVolume.UNRECOGNIZED, @@ -92,6 +91,7 @@ class ThemeSongPlayer ThemeSongVolume.HIGHEST -> 75f } player.apply { + stop() volume = volumeLevel setMediaItem(MediaItem.fromUri(url)) prepare() From 2e3e4e3f5fffab0e91389daeecb3be9c550e7802 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:36:36 -0500 Subject: [PATCH 2/5] Add long clicking context menus everywhere (#202) Adds long click context menus to just about every card throughout the app. Previously only a few pages had this. Closes #196 --- .../wholphin/data/model/BaseItem.kt | 31 +-- .../damontecres/wholphin/hilt/AppModule.kt | 7 + .../wholphin/ui/cards/BannerCard.kt | 18 ++ .../damontecres/wholphin/ui/cards/ItemRow.kt | 12 +- .../wholphin/ui/cards/PersonRow.kt | 4 +- .../ui/components/CollectionFolderGrid.kt | 102 +++++++++- .../wholphin/ui/components/GenreCardGrid.kt | 4 +- .../wholphin/ui/components/LoadingRow.kt | 4 +- .../wholphin/ui/components/Optional.kt | 31 +++ .../ui/components/RecommendedContent.kt | 178 ++++++++++++++++++ .../ui/components/RecommendedMovie.kt | 50 ++--- .../ui/components/RecommendedTvShow.kt | 51 ++--- .../wholphin/ui/detail/CardGrid.kt | 8 +- .../ui/detail/CollectionFolderGeneric.kt | 2 +- .../ui/detail/CollectionFolderLiveTv.kt | 2 +- .../ui/detail/CollectionFolderMovie.kt | 14 +- .../wholphin/ui/detail/CollectionFolderTv.kt | 5 +- .../wholphin/ui/detail/DetailUtils.kt | 74 +++++--- .../wholphin/ui/detail/FavoritesPage.kt | 6 +- .../wholphin/ui/detail/PersonPage.kt | 9 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 100 +++++++--- .../ui/detail/movie/MovieViewModel.kt | 35 ++-- .../ui/detail/series/SeriesDetails.kt | 115 +++++++++-- .../ui/detail/series/SeriesOverview.kt | 36 ++-- .../ui/detail/series/SeriesViewModel.kt | 22 +-- .../damontecres/wholphin/ui/main/HomePage.kt | 62 +++--- .../wholphin/ui/main/HomeViewModel.kt | 15 +- .../wholphin/ui/main/SearchPage.kt | 15 +- .../wholphin/util/FavoriteWatchManager.kt | 43 +++++ 29 files changed, 766 insertions(+), 289 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/Optional.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/FavoriteWatchManager.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index ed468a90..e3ffbcbd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -11,6 +11,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks +import kotlin.time.Duration @Serializable data class BaseItem( @@ -18,29 +19,29 @@ data class BaseItem( val imageUrl: String?, val backdropImageUrl: String? = null, ) { - @Transient val id = data.id + val id get() = data.id - @Transient val type = data.type + val type get() = data.type - @Transient val name = data.name + val name get() = data.name - @Transient - val title = if (type == BaseItemKind.EPISODE) data.seriesName else name + val title get() = if (type == BaseItemKind.EPISODE) data.seriesName else name - @Transient - val subtitle = - if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() - - @Transient - val resumeMs = - data.userData - ?.playbackPositionTicks - ?.ticks - ?.inWholeMilliseconds + val subtitle + get() = + if (type == BaseItemKind.EPISODE) data.seasonEpisode + " - " + name else data.productionYear?.toString() @Transient val indexNumber = data.indexNumber ?: dateAsIndex() + val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO + + val resumeMs get() = playbackPosition.inWholeMilliseconds + + val played get() = data.userData?.played ?: false + + val favorite get() = data.userData?.isFavorite ?: false + private fun dateAsIndex(): Int? = data.premiereDate ?.let { diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt index 34219c58..98eb1f81 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt @@ -11,6 +11,8 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager +import com.github.damontecres.wholphin.util.FavoriteWatchManagerImpl import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module import dagger.Provides @@ -23,6 +25,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -182,4 +185,8 @@ object AppModule { @Singleton @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun favoriteWatchManager(api: ApiClient): FavoriteWatchManager = FavoriteWatchManagerImpl(api) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 1b2e90a0..06f994f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -21,16 +21,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.isNotNullOrBlank /** @@ -45,6 +50,7 @@ fun BannerCard( modifier: Modifier = Modifier, cornerText: String? = null, played: Boolean = false, + favorite: Boolean = false, playPercent: Double = 0.0, cardHeight: Dp = 140.dp * .85f, aspectRatio: Float = 16f / 9, @@ -118,6 +124,18 @@ fun BannerCard( } } } + if (favorite) { + Text( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(8.dp), + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 16.sp, + fontFamily = FontAwesome, + ) + } if (playPercent > 0 && playPercent < 100) { Box( modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 46d38594..71df426b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -28,8 +28,8 @@ import com.github.damontecres.wholphin.util.FocusPair fun ItemRow( title: String, items: List, - onClickItem: (BaseItem) -> Unit, - onLongClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, cardContent: @Composable ( index: Int, item: BaseItem?, @@ -83,8 +83,8 @@ fun ItemRow( index, item, cardModifier, - { if (item != null) onClickItem.invoke(item) }, - { if (item != null) onLongClickItem.invoke(item) }, + { if (item != null) onClickItem.invoke(index, item) }, + { if (item != null) onLongClickItem.invoke(index, item) }, ) } } @@ -95,8 +95,8 @@ fun ItemRow( fun BannerItemRow( title: String, items: List, - onClickItem: (BaseItem) -> Unit, - onLongClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, focusPair: FocusPair? = null, cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 63730ca7..6504a13f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -28,7 +28,7 @@ fun PersonRow( people: List, onClick: (Person) -> Unit, modifier: Modifier = Modifier, - onLongClick: ((Person) -> Unit)? = null, + onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } Column( @@ -54,7 +54,7 @@ fun PersonRow( PersonCard( item = item, onClick = { onClick.invoke(item) }, - onLongClick = { onLongClick?.invoke(item) }, + onLongClick = { onLongClick?.invoke(index, item) }, modifier = Modifier .width(120.dp) 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 fa65dc3e..1622904d 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -37,12 +38,20 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.ItemViewModel +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -57,6 +66,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy +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.serializer.toUUIDOrNull @@ -71,6 +81,8 @@ class CollectionFolderViewModel @param:ApplicationContext private val context: Context, private val serverRepository: ServerRepository, private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val favoriteWatchManager: FavoriteWatchManager, + val navigationManager: NavigationManager, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) @@ -228,6 +240,24 @@ class CollectionFolderViewModel result.totalRecordCount } } + + fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } + + fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } /** @@ -241,7 +271,7 @@ fun CollectionFolderGrid( itemId: UUID, initialFilter: GetItemsFilter, recursive: Boolean, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, modifier: Modifier = Modifier, initialSortAndDirection: SortAndDirection? = null, @@ -266,14 +296,16 @@ fun CollectionFolderGrid( itemId: String, initialFilter: GetItemsFilter, recursive: Boolean, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, sortOptions: List, modifier: Modifier = Modifier, viewModel: CollectionFolderViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { + val context = LocalContext.current OneTimeLaunchedEffect { viewModel.init(itemId, initialSortAndDirection, recursive, initialFilter) } @@ -283,6 +315,10 @@ fun CollectionFolderGrid( val item by viewModel.item.observeAsState() val pager by viewModel.pager.observeAsState() + var moreDialog by remember { mutableStateOf>(Optional.absent()) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) LoadingState.Loading, @@ -297,6 +333,9 @@ fun CollectionFolderGrid( sortAndDirection = sortAndDirection!!, modifier = modifier, onClickItem = onClickItem, + onLongClickItem = { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, onSortChange = { viewModel.onSortChange(it, recursive, filter) }, @@ -308,6 +347,55 @@ fun CollectionFolderGrid( } } } + moreDialog.compose { (position, item) -> + DialogPopup( + showDialog = true, + title = item.title ?: "", + dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, watched -> + viewModel.setFavorite(position, itemId, watched) + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(it) + }, + ), + ), + onDismissRequest = { moreDialog.makeAbsent() }, + dismissOnClick = true, + waitToLoad = true, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } } @Composable @@ -316,7 +404,8 @@ fun CollectionFolderGridContent( item: BaseItem?, pager: List, sortAndDirection: SortAndDirection, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, onSortChange: (SortAndDirection) -> Unit, letterPosition: suspend (Char) -> Int, sortOptions: List, @@ -358,7 +447,7 @@ fun CollectionFolderGridContent( CardGrid( pager = pager, onClickItem = onClickItem, - onLongClickItem = {}, + onLongClickItem = onLongClickItem, letterPosition = letterPosition, gridFocusRequester = gridFocusRequester, showJumpButtons = false, // TODO add preference @@ -372,3 +461,8 @@ fun CollectionFolderGridContent( ) } } + +data class PositionItem( + val position: Int, + val item: BaseItem, +) 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 e2ce1b79..2949428d 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 @@ -103,7 +103,7 @@ fun GenreCardGrid( LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } CardGrid( pager = genres, - onClickItem = { genre -> + onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( Destination.FilteredCollection( itemId = itemId, @@ -112,7 +112,7 @@ fun GenreCardGrid( ), ) }, - onLongClickItem = {}, + onLongClickItem = { _, _ -> }, letterPosition = { viewModel.positionOfLetter(it) }, gridFocusRequester = gridFocusRequester, showJumpButtons = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt index 593f2db3..b915fdc1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/LoadingRow.kt @@ -29,7 +29,7 @@ fun LoadingRow( rowIndex: Int, position: RowColumn, focusRequester: FocusRequester, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, showIfEmpty: Boolean = true, @@ -82,7 +82,7 @@ fun LoadingRow( title = title, items = r.items, onClickItem = onClickItem, - onLongClickItem = {}, + onLongClickItem = { _, _ -> }, modifier = modifier, cardContent = cardContent, horizontalPadding = horizontalPadding, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Optional.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Optional.kt new file mode 100644 index 00000000..5d670918 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Optional.kt @@ -0,0 +1,31 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +class Optional private constructor( + initialValue: T?, +) { + private var value by mutableStateOf(initialValue) + + @Composable + fun compose(run: @Composable (T) -> Unit) { + value?.let { run.invoke(it) } + } + + fun makeAbsent() { + value = null + } + + fun makePresent(value: T) { + this.value = value + } + + companion object { + fun absent() = Optional(null) + + fun present(value: T) = Optional(value) + } +} 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 new file mode 100644 index 00000000..55e37c73 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -0,0 +1,178 @@ +package com.github.damontecres.wholphin.ui.components + +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 +import androidx.compose.ui.Modifier +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.ViewModel +import androidx.lifecycle.viewModelScope +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.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.FavoriteWatchManager +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.flow.MutableStateFlow +import org.jellyfin.sdk.model.api.MediaType +import java.util.UUID + +abstract class RecommendedViewModel( + val navigationManager: NavigationManager, + val favoriteWatchManager: FavoriteWatchManager, +) : ViewModel() { + abstract fun init() + + abstract val rows: MutableStateFlow> + + val loading = MutableLiveData(LoadingState.Loading) + + fun refreshItem( + position: RowColumn, + itemId: UUID, + ) { + viewModelScope.launchIO { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshItem(position.column, itemId) + } + } + } + + fun setWatched( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setWatched(itemId, watched) + refreshItem(position, itemId) + } + } + + fun setFavorite( + position: RowColumn, + itemId: UUID, + watched: Boolean, + ) { + viewModelScope.launchIO { + favoriteWatchManager.setFavorite(itemId, watched) + refreshItem(position, itemId) + } + } +} + +@Composable +fun RecommendedContent( + preferences: UserPreferences, + viewModel: RecommendedViewModel, + modifier: Modifier = Modifier, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + onFocusPosition: ((RowColumn) -> Unit)? = null, +) { + val context = LocalContext.current + var moreDialog by remember { mutableStateOf>(Optional.absent()) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + + OneTimeLaunchedEffect { + viewModel.init() + } + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val rows by viewModel.rows.collectAsState() + + when (val state = loading) { + is LoadingState.Error -> ErrorMessage(state) + + LoadingState.Loading, + LoadingState.Pending, + -> LoadingPage() + + LoadingState.Success -> + HomePageContent( + homeRows = rows, + onClickItem = { _, item -> + viewModel.navigationManager.navigateTo(item.destination()) + }, + onLongClickItem = { position, item -> + moreDialog.makePresent(RowColumnItem(position, item)) + }, + onFocusPosition = onFocusPosition, + showClock = preferences.appPreferences.interfacePreferences.showClock, + modifier = modifier, + ) + } + moreDialog.compose { (position, item) -> + DialogPopup( + showDialog = true, + title = item.title ?: "", + dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = { viewModel.navigationManager.navigateTo(it) }, + onClickWatch = { itemId, watched -> + viewModel.setWatched(position, itemId, watched) + }, + onClickFavorite = { itemId, watched -> + viewModel.setFavorite(position, itemId, watched) + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(it) + }, + ), + ), + onDismissRequest = { moreDialog.makeAbsent() }, + dismissOnClick = true, + waitToLoad = true, + ) + } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } +} + +private data class RowColumnItem( + val position: RowColumn, + val item: BaseItem, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 2f9e971e..132df264 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -2,25 +2,19 @@ package com.github.damontecres.wholphin.ui.components 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.ui.Modifier 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 import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler @@ -54,15 +48,15 @@ class RecommendedMovieViewModel private val api: ApiClient, private val serverRepository: ServerRepository, @Assisted val parentId: UUID, - ) : ViewModel() { + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedMovieViewModel } - val loading = MutableLiveData(LoadingState.Loading) - - val rows = + override val rows = MutableStateFlow>( rowTitles .map { @@ -72,7 +66,7 @@ class RecommendedMovieViewModel }.toMutableList(), ) - fun init() { + override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { try { val resumeItemsRequest = @@ -211,7 +205,6 @@ class RecommendedMovieViewModel fun RecommendedMovie( preferences: UserPreferences, parentId: UUID, - onClickItem: (BaseItem) -> Unit, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, viewModel: RecommendedMovieViewModel = @@ -219,27 +212,10 @@ fun RecommendedMovie( creationCallback = { it.create(parentId) }, ), ) { - OneTimeLaunchedEffect { - viewModel.init() - } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState() - - when (val state = loading) { - is LoadingState.Error -> ErrorMessage(state) - - LoadingState.Loading, - LoadingState.Pending, - -> LoadingPage() - - LoadingState.Success -> - HomePageContent( - homeRows = rows, - onClickItem = onClickItem, - onLongClickItem = {}, - onFocusPosition = onFocusPosition, - showClock = preferences.appPreferences.interfacePreferences.showClock, - modifier = modifier, - ) - } + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index d19baf0e..18cd7fbe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -2,25 +2,19 @@ package com.github.damontecres.wholphin.ui.components 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.ui.Modifier 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 import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler @@ -56,15 +50,15 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, @Assisted val parentId: UUID, - ) : ViewModel() { + navigationManager: NavigationManager, + favoriteWatchManager: FavoriteWatchManager, + ) : RecommendedViewModel(navigationManager, favoriteWatchManager) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedTvShowViewModel } - val loading = MutableLiveData(LoadingState.Loading) - - val rows = + override val rows = MutableStateFlow>( rowTitles .map { @@ -74,7 +68,7 @@ class RecommendedTvShowViewModel }.toMutableList(), ) - fun init() { + override fun init() { viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { try { val resumeItemsRequest = @@ -240,7 +234,6 @@ class RecommendedTvShowViewModel fun RecommendedTvShow( preferences: UserPreferences, parentId: UUID, - onClickItem: (BaseItem) -> Unit, onFocusPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, viewModel: RecommendedTvShowViewModel = @@ -248,28 +241,10 @@ fun RecommendedTvShow( creationCallback = { it.create(parentId) }, ), ) { - OneTimeLaunchedEffect { - viewModel.init() - } - - val loading by viewModel.loading.observeAsState(LoadingState.Loading) - val rows by viewModel.rows.collectAsState() - - when (val state = loading) { - is LoadingState.Error -> ErrorMessage(state) - - LoadingState.Loading, - LoadingState.Pending, - -> LoadingPage() - - LoadingState.Success -> - HomePageContent( - homeRows = rows, - onClickItem = onClickItem, - onLongClickItem = {}, - onFocusPosition = onFocusPosition, - showClock = preferences.appPreferences.interfacePreferences.showClock, - modifier = modifier, - ) - } + RecommendedContent( + preferences = preferences, + viewModel = viewModel, + onFocusPosition = onFocusPosition, + modifier = modifier, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 7fa770ba..4eb40eec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -71,8 +71,8 @@ private const val DEBUG = false @Composable fun CardGrid( pager: List, - onClickItem: (BaseItem) -> Unit, - onLongClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, gridFocusRequester: FocusRequester, showJumpButtons: Boolean, @@ -270,10 +270,10 @@ fun CardGrid( { if (item != null) { focusedIndex = index - onClickItem.invoke(item) + onClickItem.invoke(index, item) } }, - { if (item != null) onLongClickItem.invoke(item) }, + { if (item != null) onLongClickItem.invoke(index, item) }, mod .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) .onFocusChanged { focusState -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 06d49e1d..5898f9d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -30,7 +30,7 @@ fun CollectionFolderGeneric( var showHeader by remember { mutableStateOf(true) } CollectionFolderGrid( preferences = preferences, - onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) }, + onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = itemId, initialFilter = filter, showTitle = showHeader, 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 fdf369f2..cf74a205 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 @@ -106,7 +106,7 @@ fun CollectionFolderLiveTv( logTab("livetv", selectedTabIndex) viewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) } - val onClickItem = { item: BaseItem -> + val onClickItem = { position: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 5c156e28..f6cd23a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -69,10 +68,6 @@ fun CollectionFolderMovie( var showHeader by rememberSaveable { mutableStateOf(true) } - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( modifier = modifier, @@ -96,7 +91,6 @@ fun CollectionFolderMovie( 0 -> { RecommendedMovie( preferences = preferences, - onClickItem = onClickItem, parentId = destination.itemId, onFocusPosition = { pos -> showHeader = pos.row < 1 @@ -111,7 +105,9 @@ fun CollectionFolderMovie( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, itemId = destination.itemId, initialFilter = GetItemsFilter( @@ -133,7 +129,9 @@ fun CollectionFolderMovie( 2 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, itemId = destination.itemId, initialFilter = GetItemsFilter( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 3170a0af..cddfc04c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -95,7 +95,6 @@ fun CollectionFolderTv( RecommendedTvShow( preferences = preferences, parentId = destination.itemId, - onClickItem = onClickItem, onFocusPosition = { pos -> showHeader = pos.row < 1 }, @@ -125,7 +124,9 @@ fun CollectionFolderTv( positionCallback = { columns, position -> showHeader = position < columns }, - onClickItem = onClickItem, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, ) } 2 -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 7622436e..5fabe479 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Refresh import androidx.compose.ui.graphics.Color import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination @@ -20,6 +21,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +data class MoreDialogActions( + val navigateTo: (Destination) -> Unit, + var onClickWatch: (UUID, Boolean) -> Unit, + var onClickFavorite: (UUID, Boolean) -> Unit, + var onClickAddPlaylist: (UUID) -> Unit, +) + /** * Build the [DialogItem]s when clicking "More" * @@ -43,12 +51,9 @@ fun buildMoreDialogItems( sourceId: UUID?, watched: Boolean, favorite: Boolean, - navigateTo: (Destination) -> Unit, - onClickWatch: (Boolean) -> Unit, - onClickFavorite: (Boolean) -> Unit, + actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, - onClickAddPlaylist: () -> Unit, ): List = buildList { add( @@ -57,7 +62,7 @@ fun buildMoreDialogItems( Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { - navigateTo( + actions.navigateTo( Destination.Playback( item.id, item.resumeMs ?: 0L, @@ -119,7 +124,7 @@ fun buildMoreDialogItems( text = R.string.add_to_playlist, iconStringRes = R.string.fa_list_ul, ) { - onClickAddPlaylist.invoke() + actions.onClickAddPlaylist.invoke(item.id) }, ) add( @@ -127,7 +132,7 @@ fun buildMoreDialogItems( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, ) { - onClickWatch.invoke(!watched) + actions.onClickWatch.invoke(item.id, !watched) }, ) add( @@ -136,7 +141,7 @@ fun buildMoreDialogItems( iconStringRes = R.string.fa_heart, iconColor = if (favorite) Color.Red else Color.Unspecified, ) { - onClickFavorite.invoke(!favorite) + actions.onClickFavorite.invoke(item.id, !favorite) }, ) series?.let { @@ -145,7 +150,7 @@ fun buildMoreDialogItems( context.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, ) { - navigateTo( + actions.navigateTo( Destination.MediaItem( series.id, BaseItemKind.SERIES, @@ -160,7 +165,7 @@ fun buildMoreDialogItems( context.getString(R.string.play_with_transcoding), Icons.Default.PlayArrow, ) { - navigateTo( + actions.navigateTo( Destination.Playback( item.id, item.resumeMs ?: 0L, @@ -179,10 +184,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, - navigateTo: (Destination) -> Unit, - onClickWatch: (UUID, Boolean) -> Unit, - onClickFavorite: (UUID, Boolean) -> Unit, - onClickAddPlaylist: (UUID) -> Unit, + actions: MoreDialogActions, ): List = buildList { val itemId = item.id @@ -191,7 +193,7 @@ fun buildMoreDialogItemsForHome( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - navigateTo(item.destination()) + actions.navigateTo(item.destination()) }, ) if (item.type in supportedPlayableTypes) { @@ -202,7 +204,7 @@ fun buildMoreDialogItemsForHome( Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { - navigateTo( + actions.navigateTo( Destination.Playback( itemId, playbackPosition.inWholeMilliseconds, @@ -216,7 +218,7 @@ fun buildMoreDialogItemsForHome( Icons.Default.Refresh, // iconColor = Color.Green.copy(alpha = .8f), ) { - navigateTo( + actions.navigateTo( Destination.Playback( itemId, 0L, @@ -231,7 +233,7 @@ fun buildMoreDialogItemsForHome( Icons.Default.PlayArrow, iconColor = Color.Green.copy(alpha = .8f), ) { - navigateTo( + actions.navigateTo( Destination.Playback( itemId, 0L, @@ -246,7 +248,7 @@ fun buildMoreDialogItemsForHome( text = R.string.add_to_playlist, iconStringRes = R.string.fa_list_ul, ) { - onClickAddPlaylist.invoke(itemId) + actions.onClickAddPlaylist.invoke(itemId) }, ) add( @@ -254,7 +256,7 @@ fun buildMoreDialogItemsForHome( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, iconStringRes = if (watched) R.string.fa_eye else R.string.fa_eye_slash, ) { - onClickWatch.invoke(itemId, !watched) + actions.onClickWatch.invoke(itemId, !watched) }, ) add( @@ -263,7 +265,7 @@ fun buildMoreDialogItemsForHome( iconStringRes = R.string.fa_heart, iconColor = if (favorite) Color.Red else Color.Unspecified, ) { - onClickFavorite.invoke(itemId, !favorite) + actions.onClickFavorite.invoke(itemId, !favorite) }, ) seriesId?.let { @@ -272,7 +274,7 @@ fun buildMoreDialogItemsForHome( context.getString(R.string.go_to_series), Icons.AutoMirrored.Filled.ArrowForward, ) { - navigateTo( + actions.navigateTo( Destination.MediaItem( it, BaseItemKind.SERIES, @@ -282,3 +284,31 @@ fun buildMoreDialogItemsForHome( ) } } + +fun buildMoreDialogItemsForPerson( + context: Context, + person: Person, +// favorite: Boolean, + actions: MoreDialogActions, +): List = + buildList { + val itemId = person.id + add( + DialogItem( + context.getString(R.string.go_to), + Icons.Default.ArrowForward, + ) { + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) + }, + ) + // TODO need way to get person's favorite status +// add( +// DialogItem( +// text = if (favorite) R.string.remove_favorite else R.string.add_favorite, +// iconStringRes = R.string.fa_heart, +// iconColor = if (favorite) Color.Red else Color.Unspecified, +// ) { +// actions.onClickFavorite.invoke(itemId, !favorite) +// }, +// ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index bae04bcb..0afa076d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -99,7 +99,7 @@ fun FavoritesPage( 0 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_movies", initialFilter = GetItemsFilter( @@ -122,7 +122,7 @@ fun FavoritesPage( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_series", initialFilter = GetItemsFilter( @@ -145,7 +145,7 @@ fun FavoritesPage( 2 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_episodes", initialFilter = GetItemsFilter( 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 3e8a8d12..db2ff356 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 @@ -26,9 +26,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -186,8 +183,8 @@ fun PersonPage( movies = movies, series = series, episodes = episodes, - onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(item.destination()) }, overviewOnClick = { showOverviewDialog = true }, modifier = modifier, @@ -226,7 +223,7 @@ fun PersonPageContent( movies: RowLoadingState, series: RowLoadingState, episodes: RowLoadingState, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, ) { 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 fe784adb..f4cd91b4 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 @@ -64,14 +64,18 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandablePlayButtons import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.chooseStream import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -83,6 +87,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID +import java.util.UUID import kotlin.time.Duration @Composable @@ -112,9 +117,24 @@ fun MovieDetails( var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } var chooseVersion by remember { mutableStateOf(null) } - var showPlaylistDialog by remember { mutableStateOf(false) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + ) + when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) LoadingState.Loading, @@ -139,8 +159,8 @@ fun MovieDetails( chapters = chapters, trailers = trailers, similar = similar, - onClickItem = { - viewModel.navigateTo(it.destination()) + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) }, onClickPerson = { viewModel.navigateTo( @@ -180,9 +200,7 @@ fun MovieDetails( favorite = movie.data.userData?.isFavorite ?: false, series = null, sourceId = chosenStreams?.sourceId, - navigateTo = viewModel::navigateTo, - onClickWatch = viewModel::setWatched, - onClickFavorite = viewModel::setFavorite, + actions = moreActions, onChooseVersion = { chooseVersion = chooseVersionParams( @@ -218,18 +236,46 @@ fun MovieDetails( ) } }, - onClickAddPlaylist = { - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = true - }, ), ) }, watchOnClick = { - viewModel.setWatched((movie.data.userData?.played ?: false).not()) + viewModel.setWatched(movie.id, !movie.played) }, favoriteOnClick = { - viewModel.setFavorite((movie.data.userData?.isFavorite ?: false).not()) + viewModel.setFavorite(movie.id, !movie.favorite) + }, + onLongClickPerson = { index, person -> + val items = + buildMoreDialogItemsForPerson( + context = context, + person = person, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) + }, + onLongClickSimilar = { index, similar -> + val items = + buildMoreDialogItemsForHome( + context = context, + item = similar, + seriesId = null, + playbackPosition = similar.playbackPosition, + watched = similar.played, + favorite = similar.favorite, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = similar.title ?: "", + items = items, + ) }, trailerOnClick = { trailer -> when (trailer) { @@ -283,19 +329,19 @@ fun MovieDetails( waitToLoad = params.fromLongClick, ) } - if (showPlaylistDialog) { + showPlaylistDialog.compose { itemId -> PlaylistDialog( title = stringResource(R.string.add_to_playlist), state = playlistState, - onDismissRequest = { showPlaylistDialog = false }, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, onClick = { - playlistViewModel.addToPlaylist(it.id, destination.itemId) - showPlaylistDialog = false + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() }, createEnabled = true, onCreatePlaylist = { - playlistViewModel.createPlaylistAndAddItem(it, destination.itemId) - showPlaylistDialog = false + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() }, elevation = 3.dp, ) @@ -323,8 +369,10 @@ fun MovieDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, + onLongClickPerson: (Int, Person) -> Unit, + onLongClickSimilar: (Int, BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -422,7 +470,10 @@ fun MovieDetailsContent( position = PEOPLE_ROW onClickPerson.invoke(it) }, - onLongClick = {}, + onLongClick = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, modifier = Modifier .fillMaxWidth() @@ -466,11 +517,14 @@ fun MovieDetailsContent( ItemRow( title = stringResource(R.string.more_like_this), items = similar, - onClickItem = { + onClickItem = { index, item -> position = SIMILAR_ROW - onClickItem.invoke(it) + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) }, - onLongClickItem = {}, cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, 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 540affd8..250a8f0b 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 @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.ThemeSongPlayer @@ -39,7 +40,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi -import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest @@ -55,6 +55,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, private val themeSongPlayer: ThemeSongPlayer, + private val favoriteWatchManager: FavoriteWatchManager, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -157,25 +158,21 @@ class MovieViewModel } } - fun setWatched(played: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (played) { - api.playStateApi.markPlayedItem(itemId) - } else { - api.playStateApi.markUnplayedItem(itemId) - } - fetchAndSetItem() - } + fun setWatched( + itemId: UUID, + played: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + fetchAndSetItem() + } - fun setFavorite(favorite: Boolean) = - viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (favorite) { - api.userLibraryApi.markFavoriteItem(itemId) - } else { - api.userLibraryApi.unmarkFavoriteItem(itemId) - } - fetchAndSetItem() - } + fun setFavorite( + itemId: UUID, + favorite: Boolean, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + fetchAndSetItem() + } fun savePlayVersion( item: BaseItem, 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 6186ff80..983e5798 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 @@ -60,10 +60,17 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.SimpleStarRating +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.MoreDialogActions +import com.github.damontecres.wholphin.ui.detail.PlaylistDialog +import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination @@ -74,7 +81,9 @@ 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 import org.jellyfin.sdk.model.extensions.ticks +import java.util.UUID import kotlin.time.Duration @Composable @@ -83,6 +92,7 @@ fun SeriesDetails( destination: Destination.MediaItem, modifier: Modifier = Modifier, viewModel: SeriesViewModel = hiltViewModel(), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current LaunchedEffect(Unit) { @@ -98,6 +108,8 @@ fun SeriesDetails( var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { is LoadingState.Error -> ErrorMessage(state) @@ -126,7 +138,9 @@ fun SeriesDetails( played = played, favorite = item.data.userData?.isFavorite ?: false, modifier = modifier, - onClickItem = { viewModel.navigateTo(it.destination()) }, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, onClickPerson = { viewModel.navigateTo( Destination.MediaItem( @@ -135,7 +149,7 @@ fun SeriesDetails( ), ) }, - onLongClickItem = { season -> + onLongClickItem = { index, season -> seasonDialog = buildDialogForSeason( context = context, @@ -160,6 +174,20 @@ fun SeriesDetails( val favorite = item.data.userData?.isFavorite ?: false viewModel.setFavorite(item.id, !favorite, null) }, + moreActions = + MoreDialogActions( + navigateTo = { viewModel.navigateTo(it) }, + onClickWatch = { itemId, played -> + viewModel.setWatched(itemId, played, null) + }, + onClickFavorite = { itemId, played -> + viewModel.setFavorite(itemId, played, null) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + ), ) if (showWatchConfirmation) { ConfirmDialog( @@ -194,6 +222,23 @@ fun SeriesDetails( onDismissRequest = { seasonDialog = null }, ) } + showPlaylistDialog.compose { itemId -> + PlaylistDialog( + title = stringResource(R.string.add_to_playlist), + state = playlistState, + onDismissRequest = { showPlaylistDialog.makeAbsent() }, + onClick = { + playlistViewModel.addToPlaylist(it.id, itemId) + showPlaylistDialog.makeAbsent() + }, + createEnabled = true, + onCreatePlaylist = { + playlistViewModel.createPlaylistAndAddItem(it, itemId) + showPlaylistDialog.makeAbsent() + }, + elevation = 3.dp, + ) + } } private const val HEADER_ROW = 0 @@ -210,15 +255,17 @@ fun SeriesDetailsContent( people: List, played: Boolean, favorite: Boolean, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, - onLongClickItem: (BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, playOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + moreActions: MoreDialogActions, modifier: Modifier = Modifier, ) { + val context = LocalContext.current val scope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } @@ -227,6 +274,7 @@ fun SeriesDetailsContent( LaunchedEffect(Unit) { focusRequesters.getOrNull(position)?.tryRequestFocus() } + var moreDialog by remember { mutableStateOf(null) } Box( modifier = modifier, @@ -349,11 +397,14 @@ fun SeriesDetailsContent( ItemRow( title = stringResource(R.string.tv_seasons), items = seasons, - onClickItem = { + onClickItem = { index, item -> position = SEASONS_ROW - onClickItem.invoke(it) + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, item -> + position = SEASONS_ROW + onLongClickItem.invoke(index, item) }, - onLongClickItem = onLongClickItem, modifier = Modifier .fillMaxWidth() @@ -379,7 +430,21 @@ fun SeriesDetailsContent( position = PEOPLE_ROW onClickPerson.invoke(it) }, - onLongClick = {}, + onLongClick = { index, person -> + position = PEOPLE_ROW + val items = + buildMoreDialogItemsForPerson( + context = context, + person = person, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) + }, modifier = Modifier .fillMaxWidth() @@ -392,11 +457,29 @@ fun SeriesDetailsContent( ItemRow( title = stringResource(R.string.more_like_this), items = similar, - onClickItem = { + onClickItem = { index, item -> position = SIMILAR_ROW - onClickItem.invoke(it) + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, item -> + position = SIMILAR_ROW + val items = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = null, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = items, + ) }, - onLongClickItem = {}, cardContent = { index, item, mod, onClick, onLongClick -> SeasonCard( item = item, @@ -418,6 +501,16 @@ fun SeriesDetailsContent( } } } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } @Composable 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 52d17610..5a77d3d9 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 @@ -32,6 +32,7 @@ import com.github.damontecres.wholphin.ui.components.chooseVersionParams 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.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems @@ -196,29 +197,28 @@ fun SeriesOverview( favorite = ep.data.userData?.isFavorite ?: false, series = series, sourceId = chosenStreams?.sourceId, - navigateTo = viewModel::navigateTo, - onClickWatch = { played -> - episodeList - ?.getOrNull(position.episodeRowIndex) - ?.let { + actions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> viewModel.setWatched( - it.id, - played, + itemId, + watched, position.episodeRowIndex, ) - } - }, - onClickFavorite = { favorite -> - episodeList - ?.getOrNull(position.episodeRowIndex) - ?.let { + }, + onClickFavorite = { itemId, favorite -> viewModel.setFavorite( - it.id, + itemId, favorite, position.episodeRowIndex, ) - } - }, + }, + onClickAddPlaylist = { + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = it + }, + ), onChooseVersion = { chooseVersion = chooseVersionParams( @@ -254,10 +254,6 @@ fun SeriesOverview( ) } }, - onClickAddPlaylist = { - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = ep.id - }, ), ) 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 36b8b124..b5c99411 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 @@ -23,6 +23,7 @@ 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.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -36,9 +37,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi -import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -61,6 +60,7 @@ class SeriesViewModel private val navigationManager: NavigationManager, private val itemPlaybackRepository: ItemPlaybackRepository, private val themeSongPlayer: ThemeSongPlayer, + private val favoriteWatchManager: FavoriteWatchManager, ) : ItemViewModel(api) { private lateinit var seriesId: UUID private lateinit var prefs: UserPreferences @@ -240,11 +240,7 @@ class SeriesViewModel played: Boolean, listIndex: Int?, ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { - if (played) { - api.playStateApi.markPlayedItem(itemId) - } else { - api.playStateApi.markUnplayedItem(itemId) - } + favoriteWatchManager.setWatched(itemId, played) listIndex?.let { refreshEpisode(itemId, listIndex) } @@ -255,11 +251,7 @@ class SeriesViewModel favorite: Boolean, listIndex: Int?, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (favorite) { - api.userLibraryApi.markFavoriteItem(itemId) - } else { - api.userLibraryApi.unmarkFavoriteItem(itemId) - } + favoriteWatchManager.setFavorite(itemId, favorite) if (listIndex != null) { refreshEpisode(itemId, listIndex) } else { @@ -279,11 +271,7 @@ class SeriesViewModel fun setWatchedSeries(played: Boolean) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (played) { - api.playStateApi.markPlayedItem(seriesId) - } else { - api.playStateApi.markUnplayedItem(seriesId) - } + favoriteWatchManager.setWatched(seriesId, played) val series = fetchItem(seriesId) val seasons = getSeasons(series) this@SeriesViewModel.seasons.setValueOnMain(seasons) 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 ae67b21c..e73f0cb6 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 @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome @@ -75,7 +76,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import java.util.UUID -import kotlin.time.Duration @Composable fun HomePage( @@ -121,37 +121,36 @@ fun HomePage( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( watchingRows + latestRows, - onClickItem = { - viewModel.navigationManager.navigateTo(it.destination()) + onClickItem = { position, item -> + viewModel.navigationManager.navigateTo(item.destination()) }, - onLongClickItem = { + onLongClickItem = { position, item -> val dialogItems = buildMoreDialogItemsForHome( context = context, - item = it, - seriesId = it.data.seriesId, - playbackPosition = - it.data.userData - ?.playbackPositionTicks - ?.ticks - ?: Duration.ZERO, - watched = it.data.userData?.played ?: false, - favorite = it.data.userData?.isFavorite ?: false, - navigateTo = viewModel.navigationManager::navigateTo, - onClickWatch = { itemId, played -> - viewModel.setWatched(itemId, played) - }, - onClickFavorite = { itemId, favorite -> - viewModel.setFavorite(itemId, favorite) - }, - onClickAddPlaylist = { itemId -> - playlistViewModel.loadPlaylists(MediaType.VIDEO) - showPlaylistDialog = itemId - }, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + actions = + MoreDialogActions( + navigateTo = viewModel.navigationManager::navigateTo, + onClickWatch = { itemId, played -> + viewModel.setWatched(itemId, played) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog = itemId + }, + ), ) dialog = DialogParams( - title = it.title ?: "", + title = item.title ?: "", fromLongClick = true, items = dialogItems, ) @@ -190,8 +189,8 @@ fun HomePage( @Composable fun HomePageContent( homeRows: List, - onClickItem: (BaseItem) -> Unit, - onLongClickItem: (BaseItem) -> Unit, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, showClock: Boolean, modifier: Modifier = Modifier, onFocusPosition: ((RowColumn) -> Unit)? = null, @@ -317,14 +316,18 @@ fun HomePageContent( ItemRow( title = row.title, items = row.items, - onClickItem = onClickItem, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, cardOnFocus = { isFocused, index -> if (isFocused) { focusedItem = row.items.getOrNull(index) position = RowColumn(rowIndex, index) } }, - onLongClickItem = onLongClickItem, + onLongClickItem = { index, item -> + onLongClickItem.invoke(RowColumn(rowIndex, index), item) + }, modifier = Modifier .fillMaxWidth() @@ -339,6 +342,7 @@ fun HomePageContent( item?.data?.indexNumber?.let { "E$it" } ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, playPercent = item?.data?.userData?.playedPercentage ?: 0.0, 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 4292909e..839e282e 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.nav.NavigationManager import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.FavoriteWatchManager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -27,7 +28,6 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi -import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -50,6 +50,7 @@ class HomeViewModel val navigationManager: NavigationManager, val serverRepository: ServerRepository, val navDrawerItemRepository: NavDrawerItemRepository, + private val favoriteWatchManager: FavoriteWatchManager, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -258,11 +259,7 @@ class HomeViewModel itemId: UUID, played: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (played) { - api.playStateApi.markPlayedItem(itemId) - } else { - api.playStateApi.markUnplayedItem(itemId) - } + favoriteWatchManager.setWatched(itemId, played) withContext(Dispatchers.Main) { init(preferences) } @@ -272,11 +269,7 @@ class HomeViewModel itemId: UUID, favorite: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { - if (favorite) { - api.userLibraryApi.markFavoriteItem(itemId) - } else { - api.userLibraryApi.unmarkFavoriteItem(itemId) - } + favoriteWatchManager.setFavorite(itemId, favorite) withContext(Dispatchers.Main) { init(preferences) } 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 6d464fa7..244edbcc 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 @@ -174,6 +174,9 @@ fun SearchPage( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val onClickItem = { index: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(item.destination()) + } LazyColumn( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), @@ -207,7 +210,7 @@ fun SearchPage( rowIndex = MOVIE_ROW, position = position, focusRequester = focusRequester, - onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) @@ -217,7 +220,7 @@ fun SearchPage( rowIndex = COLLECTION_ROW, position = position, focusRequester = focusRequester, - onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) @@ -227,7 +230,7 @@ fun SearchPage( rowIndex = SERIES_ROW, position = position, focusRequester = focusRequester, - onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), ) @@ -237,7 +240,7 @@ fun SearchPage( rowIndex = EPISODE_ROW, position = position, focusRequester = focusRequester, - onClickItem = { viewModel.navigationManager.navigateTo(it.destination()) }, + onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), cardContent = @Composable { index, item, mod, onClick, onLongClick -> @@ -268,7 +271,7 @@ fun LazyListScope.searchResultRow( rowIndex: Int, position: RowColumn, focusRequester: FocusRequester, - onClickItem: (BaseItem) -> Unit, + onClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, cardContent: @Composable ( @@ -328,7 +331,7 @@ fun LazyListScope.searchResultRow( title = title, items = r.items, onClickItem = onClickItem, - onLongClickItem = {}, + onLongClickItem = { _, _ -> }, modifier = modifier, cardContent = cardContent, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/FavoriteWatchManager.kt new file mode 100644 index 00000000..72540435 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/FavoriteWatchManager.kt @@ -0,0 +1,43 @@ +package com.github.damontecres.wholphin.util + +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.UserItemDataDto +import java.util.UUID + +interface FavoriteWatchManager { + suspend fun setWatched( + itemId: UUID, + played: Boolean, + ): UserItemDataDto + + suspend fun setFavorite( + itemId: UUID, + favorite: Boolean, + ): UserItemDataDto +} + +class FavoriteWatchManagerImpl( + private val api: ApiClient, +) : FavoriteWatchManager { + override suspend fun setWatched( + itemId: UUID, + played: Boolean, + ): UserItemDataDto = + if (played) { + api.playStateApi.markPlayedItem(itemId).content + } else { + api.playStateApi.markUnplayedItem(itemId).content + } + + override suspend fun setFavorite( + itemId: UUID, + favorite: Boolean, + ): UserItemDataDto = + if (favorite) { + api.userLibraryApi.markFavoriteItem(itemId).content + } else { + api.userLibraryApi.unmarkFavoriteItem(itemId).content + } +} From 58395e9adf96bb4f861bb87e1efe40bd1e306d7a Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:44:16 -0500 Subject: [PATCH 3/5] Refactoring switching server or user (#205) Now when initiating a user or server switch, you can't back out of it and must select a user/server. This will be useful when PIN code are implemented down the road. The app will now disconnect the web socket when backgrounded. Also lots of internal refactoring --- .../damontecres/wholphin/MainActivity.kt | 29 +-- .../wholphin/data/ItemPlaybackRepository.kt | 10 +- .../wholphin/data/NavDrawerItemRepository.kt | 4 +- .../wholphin/data/ServerRepository.kt | 66 +++--- .../wholphin/data/model/JellyfinServer.kt | 6 + .../wholphin/data/model/Playlist.kt | 2 +- .../damontecres/wholphin/hilt/AppModule.kt | 6 +- .../ui/components/CollectionFolderGrid.kt | 4 +- .../ui/components/RecommendedMovie.kt | 2 +- .../ui/components/RecommendedTvShow.kt | 2 +- .../ui/detail/CollectionFolderLiveTv.kt | 2 +- .../wholphin/ui/detail/DebugPage.kt | 2 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 2 +- .../ui/detail/movie/MovieViewModel.kt | 2 +- .../ui/detail/series/SeriesOverview.kt | 2 +- .../ui/detail/series/SeriesViewModel.kt | 2 +- .../wholphin/ui/main/HomeViewModel.kt | 2 +- .../wholphin/ui/nav/ApplicationContent.kt | 5 +- .../wholphin/ui/nav/Destination.kt | 5 +- .../wholphin/ui/nav/DestinationContent.kt | 2 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 13 +- .../wholphin/ui/nav/NavigationManager.kt | 7 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 15 +- .../ui/preferences/PreferencesViewModel.kt | 4 +- .../wholphin/ui/setup/ServerList.kt | 13 +- .../wholphin/ui/setup/SwitchServerContent.kt | 9 +- .../ui/setup/SwitchServerViewModel.kt | 214 +++++++++++++++++ .../wholphin/ui/setup/SwitchUserContent.kt | 16 +- .../wholphin/ui/setup/SwitchUserViewModel.kt | 219 ++++-------------- .../wholphin/util/ServerEventListener.kt | 51 +++- app/src/main/res/values/strings.xml | 1 + 31 files changed, 442 insertions(+), 277 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt 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 c285294c..ac72c3b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -96,22 +97,19 @@ class MainActivity : AppCompatActivity() { ) { var isRestoringSession by remember { mutableStateOf(true) } LaunchedEffect(Unit) { - if (appPreferences.currentServerId.isNotBlank() && appPreferences.currentUserId.isNotBlank()) { - try { - serverRepository.restoreSession( - appPreferences.currentServerId?.toUUIDOrNull(), - appPreferences.currentUserId?.toUUIDOrNull(), - ) - } catch (ex: Exception) { - Timber.e(ex, "Exception restoring session") - } - Timber.d("MainActivity session restored") + try { + serverRepository.restoreSession( + appPreferences.currentServerId?.toUUIDOrNull(), + appPreferences.currentUserId?.toUUIDOrNull(), + ) + } catch (ex: Exception) { + Timber.e(ex, "Exception restoring session") } isRestoringSession = false } - val server = serverRepository.currentServer - val user = serverRepository.currentUser - val userDto = serverRepository.currentUserDto + val server by serverRepository.currentServer.observeAsState() + val user by serverRepository.currentUser.observeAsState() + val userDto by serverRepository.currentUserDto.observeAsState() val preferences = UserPreferences( @@ -139,8 +137,6 @@ class MainActivity : AppCompatActivity() { val initialDestination = if (server != null && user != null) { Destination.Home() - } else if (server != null) { - Destination.UserList } else { Destination.ServerList } @@ -155,9 +151,6 @@ class MainActivity : AppCompatActivity() { } } } - LaunchedEffect(server, user) { - serverEventListener.init(server, user) - } ApplicationContent( user = user, server = server, 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 27a12d13..97537146 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 @@ -25,7 +25,7 @@ class ItemPlaybackRepository itemId: UUID, item: BaseItem, ): ChosenStreams? = - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) if (itemPlayback != null) { Timber.v("Got itemPlayback for %s", itemId) @@ -72,7 +72,7 @@ class ItemPlaybackRepository sourceId: UUID, ): ItemPlayback? = withContext(Dispatchers.IO) { - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> val itemPlayback = ItemPlayback( userId = user.rowId, @@ -89,7 +89,7 @@ class ItemPlaybackRepository itemPlayback: ItemPlayback?, trackIndex: Int, type: MediaStreamType, - ) = serverRepository.currentUser?.let { user -> + ) = serverRepository.currentUser.value?.let { user -> var toSave = itemPlayback ?: ItemPlayback( userId = user.rowId, @@ -113,7 +113,9 @@ class ItemPlaybackRepository val toSave = if (itemPlayback.userId < 0) { val userRowId = - serverRepository.currentUser?.rowId?.takeIf { it >= 0 } + serverRepository.currentUser.value + ?.rowId + ?.takeIf { it >= 0 } ?: throw IllegalStateException("Trying to save an ItemPlayback without a user, but there is no current user") itemPlayback.copy(userId = userRowId) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index 11a81a62..e335cba0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -27,7 +27,7 @@ class NavDrawerItemRepository val user = serverRepository.currentUser val userViews = api.userViewsApi - .getUserViews(userId = user?.id) + .getUserViews(userId = user.value?.id) .content.items val builtins = listOf(NavDrawerItem.Favorites) @@ -46,7 +46,7 @@ class NavDrawerItemRepository } suspend fun getFilteredNavDrawerItems(items: List): List { - val user = serverRepository.currentUser + val user = serverRepository.currentUser.value val navDrawerPins = user ?.let { 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 9b910c78..f16f9aab 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 @@ -2,14 +2,15 @@ package com.github.damontecres.wholphin.data import android.content.Context import android.content.SharedPreferences -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.core.content.edit import androidx.datastore.core.DataStore +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +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.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -41,12 +42,12 @@ class ServerRepository ) { private val sharedPreferences = getServerSharedPreferences(context) - private var _currentServer by mutableStateOf(null) - val currentServer get() = _currentServer - private var _currentUser by mutableStateOf(null) - val currentUser get() = _currentUser - private var _currentUserDto by mutableStateOf(null) - val currentUserDto get() = _currentUserDto + private var _current = MutableLiveData(null) + val current: LiveData = _current + + val currentServer: LiveData get() = _current.map { it?.server } + val currentUser: LiveData get() = _current.map { it?.user } + val currentUserDto: LiveData get() = _current.map { it?.userDto } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -57,19 +58,8 @@ class ServerRepository withContext(Dispatchers.IO) { serverDao.addOrUpdateServer(server) } - changeServer(server) - } - - /** - * Updates the [ApiClient] to the server's URL - * - * The current user is removed - */ - fun changeServer(server: JellyfinServer) { apiClient.update(baseUrl = server.url, accessToken = null) - _currentServer = server - _currentUser = null - _currentUserDto = null + _current.setValueOnMain(null) } /** @@ -109,9 +99,7 @@ class ServerRepository }.build() } withContext(Dispatchers.Main) { - _currentUserDto = userDto - _currentServer = updatedServer - _currentUser = updatedUser + _current.value = CurrentUser(updatedServer, updatedUser, userDto) } sharedPreferences.edit(true) { putString(SERVER_URL_KEY, updatedServer.url) @@ -127,6 +115,7 @@ class ServerRepository userId: UUID?, ): Boolean { if (serverId == null || userId == null) { + _current.setValueOnMain(null) return false } val serverAndUsers = @@ -186,8 +175,9 @@ class ServerRepository suspend fun removeUser(user: JellyfinUser) { if (currentUser == user) { - _currentUser = null - _currentUserDto = null + withContext(Dispatchers.Main) { + _current.value = null + } userPreferencesDataStore.updateData { it .toBuilder() @@ -204,9 +194,9 @@ class ServerRepository suspend fun removeServer(server: JellyfinServer) { if (currentServer == server) { - _currentServer = null - _currentUser = null - _currentUserDto = null + withContext(Dispatchers.Main) { + _current.value = null + } userPreferencesDataStore.updateData { it .toBuilder() @@ -222,6 +212,18 @@ class ServerRepository } } + suspend fun switchServerOrUser() { + apiClient.update(baseUrl = null, accessToken = null) + userPreferencesDataStore.updateData { + it + .toBuilder() + .apply { + currentServerId = "" + currentUserId = "" + }.build() + } + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( @@ -233,3 +235,9 @@ class ServerRepository const val ACCESS_TOKEN_KEY = "current.accessToken" } } + +data class CurrentUser( + val server: JellyfinServer, + val user: JellyfinUser, + val userDto: UserDto, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index af3377cd..c9c2f6bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -1,3 +1,5 @@ +@file:UseSerializers(UUIDSerializer::class) + package com.github.damontecres.wholphin.data.model import androidx.room.ColumnInfo @@ -8,9 +10,13 @@ import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.Relation import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @Entity(tableName = "servers") +@Serializable data class JellyfinServer( @PrimaryKey val id: UUID, val name: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt index c41a0bbd..a4ab91ad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Playlist.kt @@ -145,7 +145,7 @@ class PlaylistCreator name: String, initialItems: List, ): UUID? = - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> api.playlistsApi .createPlaylist( CreatePlaylistDto( diff --git a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt index 98eb1f81..6d55525d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/hilt/AppModule.kt @@ -95,7 +95,7 @@ object AppModule { .addInterceptor { val request = it.request() val newRequest = - serverRepository.currentUser?.accessToken?.let { token -> + serverRepository.currentUser.value?.accessToken?.let { token -> request .newBuilder() .addHeader( @@ -105,7 +105,7 @@ object AppModule { clientVersion = clientInfo.version, deviceId = deviceInfo.id, deviceName = deviceInfo.name, - accessToken = serverRepository.currentUser?.accessToken, + accessToken = token, ), ).build() } @@ -149,7 +149,7 @@ object AppModule { appPreference: DataStore, @IoCoroutineScope scope: CoroutineScope, ) = object : RememberTabManager { - fun key(itemId: String) = "${serverRepository.currentServer?.id}_${serverRepository.currentUser?.id}_$itemId" + fun key(itemId: String) = "${serverRepository.currentServer.value?.id}_${serverRepository.currentUser.value?.id}_$itemId" override fun getRememberedTab( preferences: UserPreferences, 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 1622904d..2426c44a 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 @@ -108,7 +108,7 @@ class CollectionFolderViewModel val sortAndDirection = if (initialSortAndDirection == null) { - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> libraryDisplayInfoDao.getItem(user, itemId)?.sortAndDirection } ?: SortAndDirection.DEFAULT } else { @@ -123,7 +123,7 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, ) { - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> viewModelScope.launch(Dispatchers.IO) { val libraryDisplayInfo = LibraryDisplayInfo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 132df264..48388419 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -133,7 +133,7 @@ class RecommendedMovieViewModel val suggestionsRequest = GetSuggestionsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, type = listOf(BaseItemKind.MOVIE), ) val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 18cd7fbe..f504c614 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -161,7 +161,7 @@ class RecommendedTvShowViewModel val suggestionsRequest = GetSuggestionsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, type = listOf(BaseItemKind.SERIES), ) val suggestedItems = ApiRequestPager(api, suggestionsRequest, GetSuggestionsRequestHandler, viewModelScope) 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 cf74a205..d7e364fa 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 @@ -66,7 +66,7 @@ class LiveTvCollectionViewModel viewModelScope.launchIO { val folders = api.liveTvApi - .getRecordingFolders(userId = serverRepository.currentUser?.id) + .getRecordingFolders(userId = serverRepository.currentUser.value?.id) .content.items .map { TabId(it.name ?: "Recordings", it.id) } this@LiveTvCollectionViewModel.recordingFolders.setValueOnMain(folders) 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 c5a2d03f..8535c336 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 @@ -61,7 +61,7 @@ class DebugViewModel init { viewModelScope.launchIO { - serverRepository.currentUser?.rowId?.let { + serverRepository.currentUser.value?.rowId?.let { val results = itemPlaybackDao.getItems(it) withContext(Dispatchers.Main) { itemPlaybacks.value = results 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 f4cd91b4..5dc0993c 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 @@ -303,7 +303,7 @@ fun MovieDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto + viewModel.serverRepository.currentUserDto.value ?.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 250a8f0b..51c7dafa 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 @@ -147,7 +147,7 @@ class MovieViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, itemId = itemId, fields = SlimItemFields, limit = 25, 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 5a77d3d9..7d18dfec 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 @@ -346,7 +346,7 @@ fun SeriesOverview( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto + viewModel.serverRepository.currentUserDto.value ?.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 b5c99411..33a5e476 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 @@ -121,7 +121,7 @@ class SeriesViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, itemId = itemId, fields = SlimItemFields, limit = 25, 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 839e282e..d24062a5 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 @@ -77,7 +77,7 @@ class HomeViewModel ) { Timber.d("init HomeViewModel") - serverRepository.currentUserDto?.let { userDto -> + serverRepository.currentUserDto.value?.let { userDto -> val includedIds = navDrawerItemRepository .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index e1aff5ae..94cc5e33 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -10,6 +10,7 @@ import androidx.navigation3.ui.NavDisplay import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.ErrorMessage import org.jellyfin.sdk.model.api.DeviceProfile /** @@ -45,7 +46,7 @@ fun ApplicationContent( deviceProfile = deviceProfile, modifier = modifier.fillMaxSize(), ) - } else { + } else if (user != null && server != null) { NavDrawer( destination = key, preferences = preferences, @@ -54,6 +55,8 @@ fun ApplicationContent( server = server, modifier = modifier, ) + } else { + ErrorMessage("Trying to go to $key without a user logged in", null) } } }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 01b262bd..ff2ffdde 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -6,6 +6,7 @@ import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -29,7 +30,9 @@ sealed class Destination( data object ServerList : Destination(true) @Serializable - data object UserList : Destination(true) + data class UserList( + val server: JellyfinServer, + ) : Destination(true) @Serializable data class Home( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 099f4816..8ba5f51a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -54,7 +54,7 @@ fun DestinationContent( ) Destination.ServerList -> SwitchServerContent(modifier) - Destination.UserList -> SwitchUserContent(modifier) + is Destination.UserList -> SwitchUserContent(destination.server, modifier) is Destination.Settings -> PreferencesPage( 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 20b8c031..ce69e8ce 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 @@ -84,7 +84,6 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.DeviceProfile -import timber.log.Timber import java.time.LocalTime import java.util.UUID import javax.inject.Inject @@ -141,7 +140,7 @@ class NavDrawerViewModel null } } - Timber.v("Found $index => $key") +// Timber.v("Found $index => $key") if (index != null) { selectedIndex.setValueOnMain(index) break @@ -198,8 +197,8 @@ data class ServerNavDrawerItem( fun NavDrawer( destination: Destination, preferences: UserPreferences, - user: JellyfinUser?, - server: JellyfinServer?, + user: JellyfinUser, + server: JellyfinServer, deviceProfile: DeviceProfile, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -319,7 +318,11 @@ fun NavDrawer( selected = false, interactionSource = interactionSource, onClick = { - viewModel.navigationManager.navigateTo(Destination.UserList) + viewModel.navigationManager.navigateToFromDrawer( + Destination.UserList( + server, + ), + ) }, modifier = Modifier.animateItem(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt index 8a9b50a3..d07a9f9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationManager.kt @@ -28,7 +28,12 @@ class NavigationManager */ fun navigateToFromDrawer(destination: Destination) { goToHome() - backStack.add(destination) + if (destination == Destination.ServerList || destination is Destination.UserList) { + backStack.add(destination) + (0.. backStack.removeAt(0) } + } else { + backStack.add(destination) + } log() } 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 3cb73b14..82ea4881 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 @@ -268,13 +268,14 @@ class PlaybackViewModel if (itemPlayback != null) { itemPlayback } else { - val user = serverRepository.currentUser!! - itemPlaybackDao.getItem(user, base.id)?.let { - Timber.v("Fetched itemPlayback from DB: %s", it) - if (it.sourceId != null) { - it - } else { - null + serverRepository.currentUser.value?.let { user -> + itemPlaybackDao.getItem(user, base.id)?.let { + Timber.v("Fetched itemPlayback from DB: %s", it) + if (it.sourceId != null) { + it + } else { + null + } } } } 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 e73e0cef..57701e6b 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 @@ -49,7 +49,7 @@ class PreferencesViewModel init { viewModelScope.launchIO { - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } @@ -60,7 +60,7 @@ class PreferencesViewModel fun updatePins(newSelectedItems: List) { viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser?.let { user -> + serverRepository.currentUser.value?.let { user -> val disabledItems = mutableListOf().apply { addAll(allNavDrawerItems) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index a83cfb6d..827cf44b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -49,6 +49,7 @@ fun ServerList( servers: List, connectionStatus: Map, onSwitchServer: (JellyfinServer) -> Unit, + onTestServer: (JellyfinServer) -> Unit, onAddServer: () -> Unit, onRemoveServer: (JellyfinServer) -> Unit, allowAdd: Boolean, @@ -61,7 +62,7 @@ fun ServerList( items(servers) { server -> val status = connectionStatus[server.id] ?: ServerConnectionStatus.Pending ListItem( - enabled = status == ServerConnectionStatus.Success, + enabled = true, selected = false, headlineContent = { Text(text = server.name?.ifBlank { null } ?: server.url) }, supportingContent = { if (server.name.isNotNullOrBlank()) Text(text = server.url) }, @@ -77,12 +78,18 @@ fun ServerList( Icon( imageVector = Icons.Default.Warning, contentDescription = status.message, - tint = MaterialTheme.colorScheme.error, + tint = MaterialTheme.colorScheme.errorContainer, ) } } }, - onClick = { onSwitchServer.invoke(server) }, + onClick = { + when (status) { + ServerConnectionStatus.Success -> onSwitchServer.invoke(server) + ServerConnectionStatus.Pending -> {} + is ServerConnectionStatus.Error -> onTestServer.invoke(server) + } + }, onLongClick = { if (allowDelete) { showDeleteDialog = server diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 0dbce500..c90dbe41 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -43,9 +43,8 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun SwitchServerContent( modifier: Modifier = Modifier, - viewModel: SwitchUserViewModel = hiltViewModel(), + viewModel: SwitchServerViewModel = hiltViewModel(), ) { - val currentServer = viewModel.serverRepository.currentServer val servers by viewModel.servers.observeAsState(listOf()) val serverStatus by viewModel.serverStatus.observeAsState(mapOf()) @@ -90,6 +89,9 @@ fun SwitchServerContent( onSwitchServer = { viewModel.addServer(it.url) }, + onTestServer = { + viewModel.testServer(it) + }, onAddServer = { showAddServer = true }, @@ -142,6 +144,9 @@ fun SwitchServerContent( onSwitchServer = { viewModel.addServer(it.url) }, + onTestServer = { + viewModel.testServer(it) + }, onAddServer = {}, onRemoveServer = {}, allowAdd = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt new file mode 100644 index 00000000..9e962992 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -0,0 +1,214 @@ +package com.github.damontecres.wholphin.ui.setup + +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.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.client.extensions.systemApi +import org.jellyfin.sdk.model.serializer.toUUID +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@HiltViewModel +class SwitchServerViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val jellyfin: Jellyfin, + val serverRepository: ServerRepository, + val serverDao: JellyfinServerDao, + val navigationManager: NavigationManager, + ) : ViewModel() { + val servers = MutableLiveData>(listOf()) + val serverStatus = MutableLiveData>(mapOf()) + val serverQuickConnect = MutableLiveData>(mapOf()) + + val discoveredServers = MutableLiveData>(listOf()) + + val addServerState = MutableLiveData(LoadingState.Pending) + + fun clearAddServerState() { + addServerState.value = LoadingState.Pending + } + + init { + init() + } + + fun init() { + viewModelScope.launchIO { + withContext(Dispatchers.Main) { + serverStatus.value = mapOf() + serverQuickConnect.value = mapOf() + } + + val allServers = + serverDao + .getServers() + .map { it.server } + .sortedWith(compareBy { it.name }.thenBy { it.url }) + withContext(Dispatchers.Main) { + servers.value = allServers + } + allServers.forEach { server -> + internalTestServer(server) + } + } + } + + fun testServer(server: JellyfinServer) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Pending, + ) + } + viewModelScope.launchIO { + delay(1000) + val result = internalTestServer(server) + if (result == ServerConnectionStatus.Success) { + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) + } else if (result is ServerConnectionStatus.Error) { + showToast(context, result.message ?: "Error", Toast.LENGTH_SHORT) + } + } + } + + private suspend fun internalTestServer(server: JellyfinServer): ServerConnectionStatus = + try { + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + ServerConnectionStatus.Success, + ) + } + } + ServerConnectionStatus.Success + } catch (ex: Exception) { + val status = ServerConnectionStatus.Error(ex.localizedMessage) + Timber.w(ex, "Error checking server ${server.url}") + withContext(Dispatchers.Main) { + serverStatus.value = + serverStatus.value!!.toMutableMap().apply { + put( + server.id, + status, + ) + } + } + status + } + + fun addServer(serverUrl: String) { + addServerState.value = LoadingState.Loading + viewModelScope.launchIO { + try { + val serverInfo by jellyfin + .createApi(serverUrl) + .systemApi + .getPublicSystemInfo() + val id = serverInfo.id?.toUUIDOrNull() + if (id != null && serverInfo.startupWizardCompleted == true) { + val server = + JellyfinServer( + id = id, + name = serverInfo.serverName, + url = serverUrl, + ) + serverRepository.addAndChangeServer(server) + val quickConnect = + jellyfin + .createApi(serverUrl) + .quickConnectApi + .getQuickConnectEnabled() + .content + withContext(Dispatchers.Main) { + serverQuickConnect.value = + serverQuickConnect.value!!.toMutableMap().apply { + put(id, quickConnect) + } + } + withContext(Dispatchers.Main) { + addServerState.value = LoadingState.Success + navigationManager.navigateTo(Destination.UserList(server)) + } + } else { + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error("Server returned invalid response") + } + } + } catch (ex: Exception) { + Timber.w(ex, "Error creating API for $serverUrl") + withContext(Dispatchers.Main) { + addServerState.value = + LoadingState.Error(exception = ex) + } + } + } + } + + fun removeServer(server: JellyfinServer) { + viewModelScope.launchIO { + serverRepository.removeServer(server) + init() + } + } + + fun discoverServers() { + viewModelScope.launchIO { + jellyfin.discovery.discoverLocalServers().collect { server -> + val newServerList = + discoveredServers.value!! + .toMutableList() + .apply { + add( + JellyfinServer( + server.id.toUUID(), + server.name, + server.address, + ), + ) + } + withContext(Dispatchers.Main) { + discoveredServers.value = newServerList + } + } + } + } + } 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 49e9be8a..f747a612 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 @@ -39,6 +39,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -49,17 +50,20 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun SwitchUserContent( + currentServer: JellyfinServer, modifier: Modifier = Modifier, - viewModel: SwitchUserViewModel = hiltViewModel(), + viewModel: SwitchUserViewModel = + hiltViewModel( + creationCallback = { it.create(currentServer) }, + ), ) { val context = LocalContext.current - val currentServer = viewModel.serverRepository.currentServer - val currentUser = viewModel.serverRepository.currentUser +// val currentServer by viewModel.serverRepository.currentServer.observeAsState() + val currentUser by viewModel.serverRepository.currentUser.observeAsState() val users by viewModel.users.observeAsState(listOf()) - val serverQuickConnect by viewModel.serverQuickConnect.observeAsState(mapOf()) - val quickConnectEnabled = currentServer?.let { serverQuickConnect[it.id] ?: false } ?: false + val quickConnectEnabled by viewModel.serverQuickConnect.observeAsState(false) val quickConnect by viewModel.quickConnectState.observeAsState(null) var showAddUser by remember { mutableStateOf(false) } @@ -109,7 +113,7 @@ fun SwitchUserContent( users = users, currentUser = currentUser, onSwitchUser = { user -> - viewModel.switchUser(server, user) + viewModel.switchUser(user) }, onAddUser = { showAddUser = true }, onRemoveUser = { user -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 4f5a9733..abd7d4c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -8,13 +8,18 @@ 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.launchIO -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.ExceptionHandler 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.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.HttpClientOptions @@ -25,42 +30,41 @@ import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectResult -import org.jellyfin.sdk.model.serializer.toUUID -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration.Companion.seconds -@HiltViewModel +@HiltViewModel(assistedFactory = SwitchUserViewModel.Factory::class) class SwitchUserViewModel - @Inject + @AssistedInject constructor( val jellyfin: Jellyfin, val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: NavigationManager, + @Assisted val server: JellyfinServer, ) : ViewModel() { - val servers = MutableLiveData>(listOf()) - val serverStatus = MutableLiveData>(mapOf()) - val serverQuickConnect = MutableLiveData>(mapOf()) + @AssistedFactory + interface Factory { + fun create(server: JellyfinServer): SwitchUserViewModel + } + + init { + viewModelScope.launch(Dispatchers.Main + ExceptionHandler()) { + serverRepository.switchServerOrUser() + } + } + + val serverQuickConnect = MutableLiveData(false) val users = MutableLiveData>(listOf()) val quickConnectState = MutableLiveData(null) private var quickConnectJob: Job? = null - val discoveredServers = MutableLiveData>(listOf()) - - val addServerState = MutableLiveData(LoadingState.Pending) val switchUserState = MutableLiveData(LoadingState.Pending) val loginAttempts = MutableLiveData(0) - fun clearAddServerState() { - addServerState.value = LoadingState.Pending - } - fun clearSwitchUserState() { switchUserState.value = LoadingState.Pending } @@ -74,91 +78,47 @@ class SwitchUserViewModel } fun init() { + quickConnectJob?.cancel() viewModelScope.launchIO { - quickConnectJob?.cancel() + users.setValueOnMain(listOf()) + val serverUsers = + serverDao.getServer(server.id)?.users?.sortedBy { it.name } ?: listOf() withContext(Dispatchers.Main) { - users.value = listOf() - serverStatus.value = mapOf() - serverQuickConnect.value = mapOf() - } - - val allServers = - serverDao - .getServers() - .map { it.server } - .sortedWith(compareBy { it.name }.thenBy { it.url }) - withContext(Dispatchers.Main) { - servers.value = allServers - } - allServers.forEach { server -> - try { - jellyfin - .createApi( - server.url, - httpClientOptions = - HttpClientOptions( - requestTimeout = 6.seconds, - connectTimeout = 6.seconds, - socketTimeout = 6.seconds, - ), - ).systemApi - .getPublicSystemInfo() - val quickConnect by - jellyfin - .createApi(server.url) - .quickConnectApi - .getQuickConnectEnabled() - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Success, - ) - } - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(server.id, quickConnect) - } - } - } catch (ex: Exception) { - Timber.w(ex, "Error checking quick connect for server ${server.url}") - withContext(Dispatchers.Main) { - serverStatus.value = - serverStatus.value!!.toMutableMap().apply { - put( - server.id, - ServerConnectionStatus.Error(ex.localizedMessage), - ) - } - } - } + users.setValueOnMain(serverUsers) } } + viewModelScope.launchIO { - serverRepository.currentServer?.let { - val quickConnect = + try { + jellyfin + .createApi( + server.url, + httpClientOptions = + HttpClientOptions( + requestTimeout = 6.seconds, + connectTimeout = 6.seconds, + socketTimeout = 6.seconds, + ), + ).systemApi + .getPublicSystemInfo() + val quickConnect by jellyfin - .createApi(it.url) + .createApi(server.url) .quickConnectApi .getQuickConnectEnabled() - .content - val serverUsers = serverDao.getServer(it.id)?.users?.sortedBy { it.name } ?: listOf() withContext(Dispatchers.Main) { - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(it.id, quickConnect) - } - users.value = serverUsers + serverQuickConnect.value = quickConnect + } + } catch (ex: Exception) { + Timber.w(ex, "Error checking quick connect for server ${server.url}") + withContext(Dispatchers.Main) { + serverQuickConnect.value = false } } } } - fun switchUser( - server: JellyfinServer, - user: JellyfinUser, - ) { + fun switchUser(user: JellyfinUser) { viewModelScope.launchIO { try { serverRepository.changeUser(server, user) @@ -242,10 +202,7 @@ class SwitchUserViewModel if (ex is InvalidStatusException && ex.status == 401) { withContext(Dispatchers.Main) { quickConnectState.value = null - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(server.id, false) - } + serverQuickConnect.value = false } } setError("Error with Quick Connect", ex) @@ -258,55 +215,6 @@ class SwitchUserViewModel quickConnectState.value = null } - fun addServer(serverUrl: String) { - addServerState.value = LoadingState.Loading - viewModelScope.launchIO { - try { - val serverInfo by jellyfin - .createApi(serverUrl) - .systemApi - .getPublicSystemInfo() - val id = serverInfo.id?.toUUIDOrNull() - if (id != null && serverInfo.startupWizardCompleted == true) { - serverRepository.addAndChangeServer( - JellyfinServer( - id = id, - name = serverInfo.serverName, - url = serverUrl, - ), - ) - val quickConnect = - jellyfin - .createApi(serverUrl) - .quickConnectApi - .getQuickConnectEnabled() - .content - withContext(Dispatchers.Main) { - serverQuickConnect.value = - serverQuickConnect.value!!.toMutableMap().apply { - put(id, quickConnect) - } - } - withContext(Dispatchers.Main) { - addServerState.value = LoadingState.Success - navigationManager.navigateTo(Destination.UserList) - } - } else { - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error("Server returned invalid response") - } - } - } catch (ex: Exception) { - Timber.w(ex, "Error creating API for $serverUrl") - withContext(Dispatchers.Main) { - addServerState.value = - LoadingState.Error(exception = ex) - } - } - } - } - fun removeUser(user: JellyfinUser) { viewModelScope.launchIO { serverRepository.removeUser(user) @@ -318,35 +226,6 @@ class SwitchUserViewModel } } - fun removeServer(server: JellyfinServer) { - viewModelScope.launchIO { - serverRepository.removeServer(server) - init() - } - } - - fun discoverServers() { - viewModelScope.launchIO { - jellyfin.discovery.discoverLocalServers().collect { server -> - val newServerList = - discoveredServers.value!! - .toMutableList() - .apply { - add( - JellyfinServer( - server.id.toUUID(), - server.name, - server.address, - ), - ) - } - withContext(Dispatchers.Main) { - discoveredServers.value = newServerList - } - } - } - } - private suspend fun setError( msg: String? = null, ex: Exception? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt index ae36a9b3..f792f2f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ServerEventListener.kt @@ -2,13 +2,17 @@ package com.github.damontecres.wholphin.util import android.content.Context import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +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.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -20,24 +24,36 @@ import org.jellyfin.sdk.model.api.GeneralCommandType import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@ActivityScoped class ServerEventListener @Inject constructor( - @param:ApplicationContext private val context: Context, + @param:ActivityContext private val context: Context, private val api: ApiClient, - @param:IoCoroutineScope private val scope: CoroutineScope, - ) { + private val serverRepository: ServerRepository, + ) : DefaultLifecycleObserver { + private val activity = (context as AppCompatActivity) + private var listenJob: Job? = null + init { + activity.lifecycle.addObserver(this) + serverRepository.current.observe(activity) { + Timber.d("New user/server: %s", it) + listenJob?.cancel() + if (it != null) { + init(it.server, it.user) + } + } + } + fun init( server: JellyfinServer?, user: JellyfinUser?, ) { if (server != null && user != null && api.baseUrl != null && api.accessToken != null) { - scope.launchIO { + (context as AppCompatActivity).lifecycleScope.launchIO { api.sessionApi.postCapabilities( playableMediaTypes = listOf(MediaType.VIDEO), supportedCommands = @@ -53,6 +69,7 @@ class ServerEventListener } fun setupListeners() { + serverRepository.currentUser Timber.v("Subscribing to WebSocket") listenJob?.cancel() listenJob = @@ -73,6 +90,20 @@ class ServerEventListener .joinToString("\n") showToast(context, toast, Toast.LENGTH_LONG) } - }.launchIn(scope) + }.launchIn(activity.lifecycleScope) + } + + override fun onResume(owner: LifecycleOwner) { + serverRepository.current.value?.let { init(it.server, it.user) } + } + + override fun onPause(owner: LifecycleOwner) { + Timber.v("Cancelling WebSocket") + listenJob?.cancel() + } + + override fun onStop(owner: LifecycleOwner) { + Timber.v("Cancelling WebSocket") + listenJob?.cancel() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cefe044..ac425f5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -147,6 +147,7 @@ Italicize font Font Background + Success %s downloads From 5061277fd12babe4a722cd2e427124073047c399 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:36:57 -0500 Subject: [PATCH 4/5] Keep activity tracking alive even after errors (#209) Previously if the activity tracker got an error from the server during one of the reports, the timer thread would stop and no further reports would be sent. This PR fixes that so it will continue to attempt to send reports since if playback is continuing without error, the report error is likely transient. For example, I observed the server returning an error during its DB optimization scheduled task. I think this will fix #208 --- .../util/TrackActivityPlaybackListener.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt index 1e50d4a0..b0c1f593 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/TrackActivityPlaybackListener.kt @@ -4,10 +4,10 @@ import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.playback.CurrentPlayback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi @@ -49,7 +49,7 @@ class TrackActivityPlaybackListener( private var initialized = false fun init() { - coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { + launch("reportPlaybackStart") { Timber.v("reportPlaybackStart for ${itemPlayback.itemId}") api.playStateApi.reportPlaybackStart( PlaybackStartInfo( @@ -77,7 +77,7 @@ class TrackActivityPlaybackListener( task.cancel() TIMER.purge() val position = player.currentPosition.milliseconds - coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { + launch("reportPlaybackStopped") { Timber.v("reportPlaybackStopped for ${itemPlayback.itemId} at $position") api.playStateApi.reportPlaybackStopped( PlaybackStopInfo( @@ -107,7 +107,7 @@ class TrackActivityPlaybackListener( } private fun saveActivity(position: Long) { - coroutineScope.launch(Dispatchers.IO + ExceptionHandler()) { + launch("saveActivity") { val calcPosition = withContext(Dispatchers.Main) { (if (position >= 0) position else player.currentPosition) @@ -135,6 +135,19 @@ class TrackActivityPlaybackListener( } } + private fun launch( + name: String, + block: suspend CoroutineScope.() -> Unit, + ) { + coroutineScope.launchIO { + try { + block.invoke(this) + } catch (ex: Exception) { + Timber.w(ex, "Exception during %s for %s", name, itemPlayback.itemId) + } + } + } + companion object { private const val TAG = "TrackActivityPlaybackListener" From 655720fc05a8038d7b4e8232082bf9b1c84c5e90 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 13 Nov 2025 11:52:52 -0500 Subject: [PATCH 5/5] Small fix for deleting current server or user --- .../com/github/damontecres/wholphin/data/ServerRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f16f9aab..490ad701 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 @@ -174,7 +174,7 @@ class ServerRepository } suspend fun removeUser(user: JellyfinUser) { - if (currentUser == user) { + if (currentUser.value?.id == user.id) { withContext(Dispatchers.Main) { _current.value = null } @@ -193,7 +193,7 @@ class ServerRepository } suspend fun removeServer(server: JellyfinServer) { - if (currentServer == server) { + if (currentServer.value?.id == server.id) { withContext(Dispatchers.Main) { _current.value = null }