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 72b02939..a06735c4 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 @@ -99,22 +100,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( @@ -142,8 +140,6 @@ class MainActivity : AppCompatActivity() { val initialDestination = if (server != null && user != null) { Destination.Home() - } else if (server != null) { - Destination.UserList } else { Destination.ServerList } @@ -158,9 +154,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..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 @@ -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 = @@ -185,9 +174,10 @@ class ServerRepository } suspend fun removeUser(user: JellyfinUser) { - if (currentUser == user) { - _currentUser = null - _currentUserDto = null + if (currentUser.value?.id == user.id) { + withContext(Dispatchers.Main) { + _current.value = null + } userPreferencesDataStore.updateData { it .toBuilder() @@ -203,10 +193,10 @@ class ServerRepository } suspend fun removeServer(server: JellyfinServer) { - if (currentServer == server) { - _currentServer = null - _currentUser = null - _currentUserDto = null + if (currentServer.value?.id == server.id) { + 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/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/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 34219c58..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 @@ -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 @@ -92,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( @@ -102,7 +105,7 @@ object AppModule { clientVersion = clientInfo.version, deviceId = deviceInfo.id, deviceName = deviceInfo.name, - accessToken = serverRepository.currentUser?.accessToken, + accessToken = token, ), ).build() } @@ -146,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, @@ -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/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/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..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 @@ -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()) @@ -96,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 { @@ -111,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( @@ -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..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 @@ -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 = @@ -139,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) @@ -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..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 @@ -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 = @@ -167,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) @@ -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 66dd2779..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 @@ -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 @@ -65,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) @@ -102,9 +103,10 @@ fun CollectionFolderLiveTv( LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { + 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 7cef5122..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 @@ -33,6 +32,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,15 +62,12 @@ fun CollectionFolderMovie( LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { + logTab("movie", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, destination.itemId, selectedTabIndex) } var showHeader by rememberSaveable { mutableStateOf(true) } - val onClickItem = { item: BaseItem -> - preferencesViewModel.navigationManager.navigateTo(item.destination()) - } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( modifier = modifier, @@ -94,7 +91,6 @@ fun CollectionFolderMovie( 0 -> { RecommendedMovie( preferences = preferences, - onClickItem = onClickItem, parentId = destination.itemId, onFocusPosition = { pos -> showHeader = pos.row < 1 @@ -109,7 +105,9 @@ fun CollectionFolderMovie( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination()) + }, itemId = destination.itemId, initialFilter = GetItemsFilter( @@ -131,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 8155fbc5..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 @@ -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) } @@ -93,7 +95,6 @@ fun CollectionFolderTv( RecommendedTvShow( preferences = preferences, parentId = destination.itemId, - onClickItem = onClickItem, onFocusPosition = { pos -> showHeader = pos.row < 1 }, @@ -123,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/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/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 002ca4ef..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 @@ -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, @@ -97,7 +99,7 @@ fun FavoritesPage( 0 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_movies", initialFilter = GetItemsFilter( @@ -120,7 +122,7 @@ fun FavoritesPage( 1 -> { CollectionFolderGrid( preferences = preferences, - onClickItem = onClickItem, + onClickItem = { _, item -> onClickItem.invoke(item) }, itemId = "${NavDrawerItem.Favorites.id}_series", initialFilter = GetItemsFilter( @@ -143,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..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 @@ -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) { @@ -257,7 +303,7 @@ fun MovieDetails( ItemDetailsDialog( info = info, showFilePath = - viewModel.serverRepository.currentUserDto + viewModel.serverRepository.currentUserDto.value ?.policy ?.isAdministrator == true, onDismissRequest = { overviewDialog = null }, @@ -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..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 @@ -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 @@ -146,7 +147,7 @@ class MovieViewModel api.libraryApi .getSimilarItems( GetSimilarItemsRequest( - userId = serverRepository.currentUser?.id, + userId = serverRepository.currentUser.value?.id, itemId = itemId, fields = SlimItemFields, limit = 25, @@ -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..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 @@ -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 - }, ), ) @@ -350,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/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/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 36b8b124..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 @@ -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 @@ -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, @@ -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..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 @@ -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) @@ -76,7 +77,7 @@ class HomeViewModel ) { Timber.d("init HomeViewModel") - serverRepository.currentUserDto?.let { userDto -> + serverRepository.currentUserDto.value?.let { userDto -> val includedIds = navDrawerItemRepository .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) @@ -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/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 14476e70..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,8 +10,8 @@ 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 -import timber.log.Timber /** * This is generally the root composable of the of the app @@ -38,7 +38,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( @@ -47,7 +46,7 @@ fun ApplicationContent( deviceProfile = deviceProfile, modifier = modifier.fillMaxSize(), ) - } else { + } else if (user != null && server != null) { NavDrawer( destination = key, preferences = preferences, @@ -56,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 c41fd16e..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 @@ -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() } /** @@ -25,7 +28,13 @@ 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() } /** @@ -33,6 +42,7 @@ class NavigationManager */ fun goBack() { backStack.removeLastOrNull() + log() } /** @@ -45,6 +55,7 @@ class NavigationManager if (backStack[0] !is Destination.Home) { backStack[0] = Destination.Home() } + log() } /** @@ -54,5 +65,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/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 833be14c..aa4c10a5 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 @@ -272,13 +272,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/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 + } +} 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/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/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() 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" 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