diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22bd0deb..ea0b0a9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -219,6 +219,7 @@ dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) + implementation(libs.androidx.datastore.preferences) implementation(libs.protobuf.kotlin.lite) implementation(libs.androidx.tvprovider) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt index 0124ac3f..524281cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/LibraryDisplayInfoDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.ui.toServerString +import kotlinx.coroutines.flow.Flow import java.util.UUID @Dao @@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao { itemId: String, ): LibraryDisplayInfo? + @Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId") + fun getItemAsFlow( + userId: Int, + itemId: String, + ): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) fun saveItem(item: LibraryDisplayInfo): Long 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 41c1fd45..83683976 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 @@ -127,6 +127,9 @@ data class BaseItem( } else if (data.premiereDate != null) { add(data.premiereDate!!.toLocalDate().toString()) } + } else if (type == BaseItemKind.BOX_SET) { + data.productionYear?.let { add(it.toString()) } + data.childCount?.let { add("$it items") } } else { data.productionYear?.let { add(it.toString()) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt index eb8ffdd1..e5bcafcb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/LibraryDisplayInfo.kt @@ -9,12 +9,14 @@ import androidx.room.Ignore import androidx.room.Index import com.github.damontecres.wholphin.ui.components.ViewOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.toServerString import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID @Entity( foreignKeys = [ @@ -41,4 +43,13 @@ data class LibraryDisplayInfo( ) { @Ignore @Transient val sortAndDirection = SortAndDirection(sort, direction) + + constructor( + user: JellyfinUser, + itemId: UUID, + sort: ItemSortBy, + direction: SortOrder, + filter: GetItemsFilter, + viewOptions: ViewOptions?, + ) : this(user.rowId, itemId.toServerString(), sort, direction, filter, viewOptions) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt new file mode 100644 index 00000000..543ea656 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/KeyValueService.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Gets/saves a serializable object by name + */ +@Singleton +class KeyValueService + @Inject + constructor( + val dataStore: DataStore, + ) { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + inline fun get(key: String): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } + } + + inline fun get( + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey(key)]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + inline fun get( + userId: UUID, + key: String, + defaultValue: T, + ): Flow = + dataStore.data.map { preferences -> + preferences[stringPreferencesKey("${userId}_$key")]?.let { + json.decodeFromString(serializer(), it) + } ?: defaultValue + } + + suspend inline fun save( + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey(key), valueStr) + } + } + } + + suspend inline fun save( + userId: UUID, + key: String, + value: T, + ) { + dataStore.updateData { preferences -> + val valueStr = json.encodeToString(value) + preferences.toMutablePreferences().apply { + set(stringPreferencesKey("${userId}_$key"), valueStr) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 31c92774..62609628 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.github.damontecres.wholphin.data.AppDatabase import com.github.damontecres.wholphin.data.ItemPlaybackDao @@ -85,4 +88,13 @@ object DatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } 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 a42d027f..4d69fe0c 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 @@ -39,6 +39,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -437,6 +438,16 @@ fun Int?.gt(that: Int) = (this ?: 0) > that fun Int?.lt(that: Int) = (this ?: 0) < that +/** + * Simplifies endlessly collecting a flow + */ +fun Flow.collectLatestIn( + scope: CoroutineScope, + action: suspend (value: T) -> Unit, +) { + scope.launchDefault { this@collectLatestIn.collectLatest(action) } +} + /** * Easy way to combine two flows into a [Pair] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 0a9b9a2c..7435cca8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber import java.time.LocalDate @@ -196,3 +197,45 @@ fun listToDotString( } } } + +@StringRes +fun formatTypeName(type: BaseItemKind): Int = + when (type) { + BaseItemKind.MOVIE -> R.string.movies + BaseItemKind.SERIES -> R.string.tv_shows + BaseItemKind.EPISODE -> R.string.episodes + BaseItemKind.VIDEO -> R.string.videos + BaseItemKind.PLAYLIST -> R.string.playlists + BaseItemKind.PERSON -> R.string.people + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.AUDIO -> TODO() + BaseItemKind.CHANNEL -> R.string.channels + BaseItemKind.GENRE -> R.string.genres + BaseItemKind.LIVE_TV_CHANNEL -> R.string.channels + BaseItemKind.MUSIC_ALBUM -> TODO() + BaseItemKind.MUSIC_ARTIST -> TODO() + BaseItemKind.MUSIC_GENRE -> TODO() + BaseItemKind.MUSIC_VIDEO -> TODO() + BaseItemKind.PHOTO -> R.string.photos + BaseItemKind.PHOTO_ALBUM -> TODO() + BaseItemKind.PROGRAM -> TODO() + BaseItemKind.RECORDING -> TODO() + BaseItemKind.SEASON -> R.string.tv_seasons + BaseItemKind.STUDIO -> R.string.studios + BaseItemKind.TRAILER -> R.string.trailers + BaseItemKind.TV_CHANNEL -> R.string.channels + BaseItemKind.TV_PROGRAM -> TODO() + BaseItemKind.USER_ROOT_FOLDER -> TODO() + BaseItemKind.USER_VIEW -> TODO() + BaseItemKind.YEAR -> TODO() + BaseItemKind.AGGREGATE_FOLDER -> TODO() + BaseItemKind.AUDIO_BOOK -> TODO() + BaseItemKind.BASE_PLUGIN_FOLDER -> TODO() + BaseItemKind.BOOK -> TODO() + BaseItemKind.CHANNEL_FOLDER_ITEM -> TODO() + BaseItemKind.COLLECTION_FOLDER -> TODO() + BaseItemKind.FOLDER -> TODO() + BaseItemKind.MANUAL_PLAYLISTS_FOLDER -> TODO() + BaseItemKind.LIVE_TV_PROGRAM -> TODO() + BaseItemKind.PLAYLISTS_FOLDER -> TODO() + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt deleted file mode 100644 index 07c24c37..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid -import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions -import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import java.util.UUID - -@Composable -fun CollectionFolderBoxSet( - preferences: UserPreferences, - itemId: UUID, - recursive: Boolean, - modifier: Modifier = Modifier, - filter: CollectionFolderFilter = CollectionFolderFilter(), - preferencesViewModel: PreferencesViewModel = hiltViewModel(), - playEnabled: Boolean = false, -) { - var showHeader by remember { mutableStateOf(true) } - CollectionFolderGrid( - preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, - itemId = itemId, - initialFilter = filter, - showTitle = showHeader, - recursive = recursive, - sortOptions = BoxSetSortOptions, - initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = modifier, - positionCallback = { columns, position -> - showHeader = position < columns - }, - defaultViewOptions = ViewOptionsPoster, - playEnabled = playEnabled, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt new file mode 100644 index 00000000..b10e9a17 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionButtons.kt @@ -0,0 +1,148 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.components.DeleteButton +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.FilterByButton +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.MovieSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlin.time.Duration + +@Composable +fun CollectionButtons( + state: CollectionState, + onSortChange: (SortAndDirection) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + onClickViewOptions: () -> Unit, + favoriteOnClick: () -> Unit, + deleteOnClick: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = MovieSortOptions + val filterOptions = DefaultFilterOptions + val firstFocus = remember { FocusRequester() } + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier, + ) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item { + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = Icons.Default.PlayArrow, + onClick = { onClickPlayAll.invoke(false) }, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ExpandableFaButton( + title = R.string.shuffle, + iconStringRes = R.string.fa_shuffle, + onClick = { onClickPlayAll.invoke(true) }, + ) + } + + item("favorite") { + val favorite = remember(state.collection) { state.collection?.favorite == true } + ExpandableFaButton( + title = if (favorite) R.string.remove_favorite else R.string.add_favorite, + iconStringRes = R.string.fa_heart, + onClick = favoriteOnClick, + iconColor = if (favorite) Color.Red else Color.Unspecified, + modifier = Modifier, + ) + } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier, + ) + } + } + + item { + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = onClickViewOptions, + modifier = Modifier, + ) + } + + // More button + item("more") { + ExpandablePlayButton( + title = R.string.more, + resume = Duration.ZERO, + icon = Icons.Default.MoreVert, + onClick = { moreOnClick.invoke() }, + modifier = Modifier, + ) + } + } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .focusGroup(), + ) { + item { + SortByButton( + sortOptions = sortOptions, + current = state.sortAndDirection, + onSortChange = onSortChange, + modifier = Modifier, + ) + } + item { + FilterByButton( + filterOptions = filterOptions, + current = state.itemFilter, + onFilterChange = onFilterChange, + getPossibleValues = getPossibleFilterValues, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt new file mode 100644 index 00000000..7f9cc1ca --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetails.kt @@ -0,0 +1,551 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +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 com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +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.ConfirmDeleteDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.Optional +import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.data.SortAndDirection +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.main.HomePageHeader +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber +import java.util.UUID + +@Composable +fun CollectionDetails( + preferences: UserPreferences, + itemId: UUID, + modifier: Modifier = Modifier, + viewModel: CollectionViewModel = + hiltViewModel( + creationCallback = { it.create(itemId) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val state by viewModel.state.collectAsState() + + // Dialogs + var moreDialog by remember { mutableStateOf(null) } + var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) } + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf?>(null) } + var showViewOptionsDialog by remember { mutableStateOf(false) } + var overviewDialog by remember { mutableStateOf(null) } + + // Actions + val onClickItem = + remember { + { _: RowColumn, item: BaseItem -> viewModel.navigate(item.destination()) } + } + val onLongClickItem = + remember { + { position: RowColumn, item: BaseItem -> + val dialogItems = + buildMoreDialogItemsForHome( + context = context, + item = item, + seriesId = item.data.seriesId, + playbackPosition = item.playbackPosition, + watched = item.played, + favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + actions = + MoreDialogActions( + navigateTo = viewModel::navigate, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, position) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, position) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { item -> showDeleteDialog = Pair(position, item) }, + ), + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = item.title ?: "", + items = dialogItems, + ) + } + } + val onSortChange = + remember { + { sort: SortAndDirection -> viewModel.changeSort(sort) } + } + val onFilterChange = + remember { + { filter: GetItemsFilter -> viewModel.changeFilter(filter) } + } + val onClickPlay = { _: RowColumn, item: BaseItem -> + viewModel.navigate(Destination.Playback(item = item)) + } + val onClickPlayAll = + remember { + { shuffle: Boolean -> + val dest = + Destination.PlaybackList( + itemId = itemId, + startIndex = 0, + shuffle = shuffle, + recursive = true, + sortAndDirection = state.sortAndDirection, + filter = state.itemFilter, + ) + viewModel.navigate(dest) + } + } + val onClickViewOptions = remember { { showViewOptionsDialog = true } } + + when (val s = state.loadingState) { + is LoadingState.Error -> { + ErrorMessage(s, modifier) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + CollectionDetailsContent( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onSortChange = onSortChange, + onClickPlay = onClickPlay, + onClickPlayAll = onClickPlayAll, + onChangeBackdrop = viewModel::updateBackdrop, + onFilterChange = onFilterChange, + getPossibleFilterValues = viewModel::getPossibleFilterValues, + letterPosition = viewModel::letterPosition, + onClickViewOptions = onClickViewOptions, + modifier = modifier, + overviewOnClick = { + val collection = state.collection!! + overviewDialog = + ItemDetailsDialogInfo( + title = collection.title ?: "", + overview = collection.data.overview, + genres = collection.data.genres.orEmpty(), + files = emptyList(), + ) + }, + favoriteOnClick = + remember { + { + state.collection?.let { + viewModel.setFavorite(it.id, !it.favorite, null) + } + } + }, + deleteOnClick = + remember { + { + state.collection?.let { + viewModel.deleteItem(it, null) + } + } + }, + canDelete = + remember(state.collection) { + state.collection?.let { + viewModel.canDelete(it, preferences.appPreferences) + } ?: false + }, + moreOnClick = { + val collection = state.collection!! + val items = + buildMoreDialogItemsForCollection( + context = context, + item = collection, + favorite = collection.favorite, + canDelete = viewModel.canDelete(collection, preferences.appPreferences), + onClickPlayAll = onClickPlayAll, + actions = + MoreDialogActions( + navigateTo = viewModel::navigate, + onClickWatch = { itemId, watched -> + viewModel.setWatched(itemId, watched, null) + }, + onClickFavorite = { itemId, favorite -> + viewModel.setFavorite(itemId, favorite, null) + }, + onClickAddPlaylist = { itemId -> + playlistViewModel.loadPlaylists(MediaType.VIDEO) + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { item -> showDeleteDialog = Pair(null, item) }, + ), + ) + moreDialog = + DialogParams( + fromLongClick = false, + title = collection.title ?: "", + items = items, + ) + }, + ) + } + } + if (showViewOptionsDialog) { + CollectionViewOptionsDialog( + viewOptions = state.viewOptions, + onDismissRequest = { showViewOptionsDialog = false }, + onViewOptionsChange = viewModel::changeViewOptions, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } + 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, + ) + } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item, position) + showDeleteDialog = null + }, + ) + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = + viewModel.serverRepository.currentUserDto.value + ?.policy + ?.isAdministrator == true, + onDismissRequest = { overviewDialog = null }, + ) + } +} + +@Composable +fun CollectionDetailsContent( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onSortChange: (SortAndDirection) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + onClickPlayAll: (Boolean) -> Unit, + onChangeBackdrop: (BaseItem) -> Unit, + onFilterChange: (GetItemsFilter) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + letterPosition: suspend (Char) -> Int, + onClickViewOptions: () -> Unit, + overviewOnClick: () -> Unit, + favoriteOnClick: () -> Unit, + deleteOnClick: () -> Unit, + canDelete: Boolean, + moreOnClick: () -> Unit, + modifier: Modifier, +) { + var itemsContentHasFocus by rememberSaveable { mutableStateOf(false) } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val focusRequester = remember { FocusRequester() } + val contentFocusRequester = remember { FocusRequester() } + + var focusedItem by remember { mutableStateOf(state.collection) } + LaunchedEffect(focusedItem) { + focusedItem?.let { onChangeBackdrop.invoke(it) } + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + SharedTransitionLayout { + AnimatedContent( + targetState = itemsContentHasFocus, + label = "header_transition", + ) { targetState -> + if (targetState) { + // Show item header + LaunchedEffect(Unit) { + contentFocusRequester.tryRequestFocus() + } + Column( + Modifier.sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { it / 2 } + fadeIn(), + exit = slideOutVertically { it / 2 } + fadeOut(), + ), + ) { + // This box exists so that there is something focusable above the item content + // allowing focus to move up to restore the collection's header + Box( + modifier = + Modifier + .fillMaxWidth() + .height(0.dp) + .onFocusChanged { + if (it.isFocused) itemsContentHasFocus = false + }.focusable(), + ) + if (state.viewOptions.cardViewOptions.showDetails) { + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .padding(top = 48.dp, start = 8.dp) + .fillMaxHeight(.33f) + .fillMaxWidth(), + ) + } + } + } else { + // Show collection header + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + focusedItem = state.collection + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .sharedBounds( + rememberSharedContentState(key = "header"), + animatedVisibilityScope = this@AnimatedContent, + enter = slideInVertically { -it / 2 } + fadeIn(), + exit = slideOutVertically { -it / 2 } + fadeOut(), + ).padding(bottom = 16.dp) + .fillMaxWidth() + .onFocusChanged { + if (it.hasFocus) { + onChangeBackdrop.invoke(state.collection!!) + } + }, + ) { + CollectionDetailsHeader( + collection = state.collection!!, + logoImageUrl = state.logoImageUrl, + overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, + modifier = + Modifier + .padding(top = 48.dp, start = 8.dp) + // TODO + .fillMaxHeight(.36f) + .fillMaxWidth(), + ) + CollectionButtons( + state = state, + onSortChange = onSortChange, + onClickPlayAll = onClickPlayAll, + onFilterChange = onFilterChange, + getPossibleFilterValues = getPossibleFilterValues, + onClickViewOptions = onClickViewOptions, + favoriteOnClick = favoriteOnClick, + deleteOnClick = deleteOnClick, + canDelete = canDelete, + moreOnClick = moreOnClick, + modifier = + Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + ) + } + } + } + } + Box( + modifier = + Modifier + .fillMaxSize() + .onFocusChanged { + if (it.hasFocus) itemsContentHasFocus = true + }.focusProperties { + up = focusRequester + }.focusRequester(contentFocusRequester), + ) { + if (state.viewOptions.separateTypes) { + CollectionRows( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { position -> + Timber.v("onFocusPosition=%s", position) + focusedItem = + position.let { + val key = + state.separateItems.keys + .toList() + .getOrNull(it.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items?.getOrNull( + it.column, + ) + } + }, + ) + } else { + CollectionMixedGrid( + preferences = preferences, + state = state, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + modifier = Modifier.fillMaxSize(), + onFocusPosition = { + Timber.v("onFocusPosition=%s", it) + focusedItem = state.items.getOrNull(it.column) + }, + ) + } + } + } +} + +fun buildMoreDialogItemsForCollection( + context: Context, + item: BaseItem, + favorite: Boolean, + canDelete: Boolean, + onClickPlayAll: (shuffle: Boolean) -> Unit, + actions: MoreDialogActions, +): List = + buildList { + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlayAll.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlayAll.invoke(true) + }, + ) + + add( + DialogItem( + text = R.string.add_to_playlist, + iconStringRes = R.string.fa_list_ul, + ) { + actions.onClickAddPlaylist.invoke(item.id) + }, + ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } + 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(item.id, !favorite) + }, + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt new file mode 100644 index 00000000..a71ac187 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionDetailsHeader.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch + +@Composable +fun CollectionDetailsHeader( + collection: BaseItem, + logoImageUrl: String?, + overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, + modifier: Modifier = Modifier, +) { + val dto = collection.data + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + if (logoImageUrl != null) { + AsyncImage( + model = logoImageUrl, + contentDescription = collection.name, + modifier = Modifier.height(80.dp), + alignment = Alignment.TopStart, + contentScale = ContentScale.Fit, + ) + } else { + // Title + Text( + text = collection.name ?: "", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + QuickDetails( + collection.ui.quickDetails, + collection.timeRemainingOrRuntime, + Modifier.padding(start = 8.dp), + ) + + dto.genres?.letNotEmpty { + GenreText(it, Modifier.padding(start = 8.dp)) + } + dto.taglines?.firstOrNull()?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier.padding(start = 8.dp), + ) + } + + // Description + dto.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt new file mode 100644 index 00000000..64a6eb10 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionMixedGrid.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.GridCard +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.playback.scale +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec +import org.jellyfin.sdk.model.api.ItemSortBy + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionMixedGrid( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + letterPosition: suspend (Char) -> Int, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + val gridFocusRequester = remember { FocusRequester() } + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.fillMaxSize(), + ) { + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current + + val cardViewOptions = state.viewOptions.cardViewOptions + CardGrid( + pager = state.items, + onClickItem = { index: Int, item: BaseItem -> onClickItem.invoke(RowColumn(0, index), item) }, + onLongClickItem = { index: Int, item: BaseItem -> onLongClickItem.invoke(RowColumn(0, index), item) }, + onClickPlay = { index: Int, item: BaseItem -> onClickPlay.invoke(RowColumn(0, index), item) }, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = state.sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = + Modifier + .fillMaxSize(), + initialPosition = 0, + positionCallback = { _, newPosition -> + onFocusPosition.invoke(RowColumn(0, newPosition)) + }, + cardContent = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = cardViewOptions.contentScale.scale, + imageAspectRatio = cardViewOptions.aspectRatio.ratio, + imageType = cardViewOptions.imageType, + showTitle = cardViewOptions.showTitles, + modifier = mod, + ) + }, + columns = cardViewOptions.columns, + spacing = cardViewOptions.spacing.dp, + bringIntoViewSpec = + remember(cardViewOptions) { + val spacingPx = with(density) { cardViewOptions.spacing.dp.toPx() } + if (cardViewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt new file mode 100644 index 00000000..ef78fb3b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionRows.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import org.jellyfin.sdk.model.api.BaseItemKind + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CollectionRows( + preferences: UserPreferences, + state: CollectionState, + onClickItem: (RowColumn, BaseItem) -> Unit, + onLongClickItem: (RowColumn, BaseItem) -> Unit, + onClickPlay: (RowColumn, BaseItem) -> Unit, + modifier: Modifier = Modifier, + onFocusPosition: (RowColumn) -> Unit = {}, +) { + var position by rememberPosition(0, 0) + + Box(modifier = modifier) { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxSize() + .padding(top = 8.dp), + ) { + val cardViewOptions = state.viewOptions.cardViewOptions + val homeRows = + remember(state.separateItems, cardViewOptions) { + state.separateItems.map { (type, row) -> + if (row is HomeRowLoadingState.Success) { + // TODO not great to do this in the UI + val viewOptions = + if (type == BaseItemKind.EPISODE) { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + episodeAspectRatio = AspectRatio.WIDE, + showTitles = cardViewOptions.showTitles, + useSeries = false, + ) + } else { + HomeRowViewOptions( + showTitles = cardViewOptions.showTitles, + ) + } + row.copy(viewOptions = viewOptions) + } else { + row + } + } + } + HomePageContent( + homeRows = homeRows, + position = position, + onFocusPosition = { newPosition -> + position = newPosition + onFocusPosition.invoke(newPosition) + }, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + showClock = false, + onUpdateBackdrop = {}, + headerComposable = {}, + takeFocus = false, + modifier = Modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt new file mode 100644 index 00000000..badc4e9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewModel.kt @@ -0,0 +1,493 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.collectLatestIn +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel(assistedFactory = CollectionViewModel.Factory::class) +class CollectionViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val serverRepository: ServerRepository, + private val navigationManager: NavigationManager, + private val preferencesService: UserPreferencesService, + private val themeSongPlayer: ThemeSongPlayer, + private val mediaManagementService: MediaManagementService, + private val favoriteWatchManager: FavoriteWatchManager, + private val backdropService: BackdropService, + private val keyValueService: KeyValueService, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, + private val imageUrlService: ImageUrlService, + val mediaReportService: MediaReportService, + @Assisted private val itemId: UUID, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(itemId: UUID): CollectionViewModel + } + + private val viewOptionsFlow = + serverRepository.currentUser + .asFlow() + .filterNotNull() + .flatMapLatest { + keyValueService.get(it.id, VIEW_OPTIONS_KEY, CollectionViewOptions()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), CollectionViewOptions()) + + private val libraryDisplayInfoFlow = + serverRepository.currentUser + .asFlow() + .filterNotNull() + .flatMapLatest { + libraryDisplayInfoDao.getItemAsFlow(it.rowId, itemId.toServerString()) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) + + private val _state = MutableStateFlow(CollectionState()) + val state: StateFlow = _state + + init { + addCloseable { release() } + // Get global per-user view options for collections + viewOptionsFlow.collectLatestIn(viewModelScope) { viewOptions -> + Timber.v("Updated viewOptions") + _state.update { + it.copy(viewOptions = viewOptions) + } + } + libraryDisplayInfoFlow + .filterNotNull() + .collectLatestIn(viewModelScope) { libraryDisplayInfo -> + Timber.v("Updated libraryDisplayInfo") + _state.update { + it.copy( + itemFilter = libraryDisplayInfo.filter, + sortAndDirection = libraryDisplayInfo.sortAndDirection, + ) + } + } + viewModelScope.launchDefault { + val collection = + api.userLibraryApi + .getItem(itemId) + .content + .let { BaseItem(it, false) } + backdropService.submit(collection) + val logoImageUrl = null + // TODO add logo back +// if (ImageType.LOGO in collection.data.imageTags.orEmpty()) { +// imageUrlService.getItemImageUrl(collection, ImageType.LOGO) +// } else { +// null +// } + _state.update { + it.copy( + collection = collection, + logoImageUrl = logoImageUrl, + ) + } + listenForStateUpdates() + themeSongPlayer.playThemeFor( + itemId, + preferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs, + ) + } + } + + fun release() { + themeSongPlayer.stop() + } + + /** + * Collects on [state] and fetches data when needed + */ + private fun listenForStateUpdates() = + viewModelScope.launchDefault { + state + .map { + Triple( + it.sortAndDirection, + it.itemFilter, + it.viewOptions.separateTypes, + ) + }.distinctUntilChanged() + .collectLatest { (sort, filter, separateTypes) -> + try { + updateData(sort, filter, separateTypes) + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching data for collection %s", + itemId, + ) + _state.update { it.copy(loadingState = LoadingState.Error(ex)) } + } + } + } + + private suspend fun updateData( + sort: SortAndDirection, + filter: GetItemsFilter, + separateTypes: Boolean, + ) { + Timber.d("Begin updateData for %s", itemId) + _state.update { + it.copy( + loadingState = LoadingState.Loading, + items = emptyList(), + separateItems = emptyMap(), + ) + } + if (!separateTypes) { + val result = fetchItems(sort, filter, typesInCollection) + _state.update { it.copy(items = result) } + } else { + supervisorScope { + val jobs = + typesInCollection.map { type -> + async(Dispatchers.IO) { + val title = context.getString(formatTypeName(type)) + val result = + try { + val pager = fetchItems(sort, filter, listOf(type)) + HomeRowLoadingState.Success(title, pager) + } catch (ex: Exception) { + Timber.e( + ex, + "Error fetching %s for collection %s", + type, + itemId, + ) + HomeRowLoadingState.Error(title, exception = ex) + } + type to result + } + } + jobs.forEach { job -> + val (type, row) = job.await() + _state.update { + val separateItems = + it.separateItems.toMutableMap().apply { + put(type, row) + } + it.copy(separateItems = separateItems) + } + } + } + } + _state.update { it.copy(loadingState = LoadingState.Success) } + Timber.d("End updateData for %s", itemId) + } + + private suspend fun fetchItems( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List, + ): ApiRequestPager { + val request = createGetItemsRequest(sort, filter, types) + val useSeriesForPrimary = !state.value.viewOptions.separateTypes + return ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ).init() + } + + private fun createGetItemsRequest( + sort: SortAndDirection?, + filter: GetItemsFilter?, + types: List, + ): GetItemsRequest { + val includeItemTypes: List? + val excludeItemTypes: List? + // Workaround for https://github.com/jellyfin/jellyfin/issues/16454 + if (types.size == 1 && types.first() == BaseItemKind.BOX_SET) { + includeItemTypes = null + excludeItemTypes = + BaseItemKind.entries + .toMutableList() + .apply { remove(BaseItemKind.BOX_SET) } + } else { + includeItemTypes = types + excludeItemTypes = null + } + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + includeItemTypes = includeItemTypes, + excludeItemTypes = excludeItemTypes, + recursive = false, + sortBy = sort?.let { listOf(sort.sort) }, + sortOrder = sort?.let { listOf(sort.direction) }, + fields = SlimItemFields, + ).let { + filter?.applyTo(it, false) ?: it + } + return request + } + + fun changeSort(sortAndDirection: SortAndDirection) { + viewModelScope.launchIO { + val user = serverRepository.currentUser.value + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = state.itemFilter, + viewOptions = null, + ), + ) + } + } + } + + fun changeFilter(filter: GetItemsFilter) { + viewModelScope.launchIO { + val user = serverRepository.currentUser.value + val state = _state.value + if (user != null) { + libraryDisplayInfoDao.saveItem( + LibraryDisplayInfo( + user = user, + itemId = itemId, + sort = state.sortAndDirection.sort, + direction = state.sortAndDirection.direction, + filter = filter, + viewOptions = null, + ), + ) + } + } + } + + fun changeViewOptions(viewOptions: CollectionViewOptions) { + viewModelScope.launchIO { + if (!viewOptions.cardViewOptions.showDetails) { + val collection = state.value.collection + if (collection != null) { + backdropService.submit(collection) + } else { + backdropService.clearBackdrop() + } + } + serverRepository.currentUser.value?.id?.let { userId -> + keyValueService.save(userId, VIEW_OPTIONS_KEY, viewOptions) + } + } + } + + suspend fun getPossibleFilterValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemId, + filterOption, + ) + + suspend fun letterPosition(letter: Char): Int = + withContext(Dispatchers.IO) { + val sort = state.value.sortAndDirection + val filter = state.value.itemFilter + val request = + createGetItemsRequest( + sort = sort, + filter = filter, + types = typesInCollection, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount + } + + fun navigate(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun setWatched( + itemId: UUID, + played: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { + favoriteWatchManager.setWatched(itemId, played) + if (position != null) { + refreshItem(itemId, position, false) + } + } + + fun setFavorite( + itemId: UUID, + favorite: Boolean, + position: RowColumn?, + ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + if (position != null) { + refreshItem(itemId, position, false) + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun deleteItem( + item: BaseItem, + position: RowColumn?, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchIO { + if (position != null) { + refreshItem(itemId, position, true) + } + } + } + } + + private suspend fun refreshItem( + itemId: UUID, + position: RowColumn, + isDelete: Boolean, + ) { + state.value.let { state -> + val items = + if (state.viewOptions.separateTypes) { + val key = + state.separateItems.keys + .toList() + .getOrNull(position.row) + (state.separateItems[key] as? HomeRowLoadingState.Success)?.items as? ApiRequestPager<*> + } else { + state.items as? ApiRequestPager<*> + } + if (isDelete) { + items?.refreshPagesAfter(position.column) + } else { + items?.refreshItem(position.column, itemId) + } + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchDefault { + val collection = state.value.collection + if (item.id == collection?.id) { + // Always show the collection's backdrop if requested + backdropService.submit(collection) + } else if (state.value.viewOptions.cardViewOptions.showDetails) { + backdropService.submit(item) + } else { + backdropService.clearBackdrop() + } + } + } + + companion object { + val typesInCollection = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.VIDEO, + BaseItemKind.BOX_SET, + ) + + const val VIEW_OPTIONS_KEY = "CollectionViewOptions" + } + } + +@Stable +data class CollectionState( + val loadingState: LoadingState = LoadingState.Pending, + val collection: BaseItem? = null, + val sortAndDirection: SortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + val itemFilter: GetItemsFilter = GetItemsFilter(), + val viewOptions: CollectionViewOptions = CollectionViewOptions(), + val items: List = emptyList(), + val separateItems: Map = emptyMap(), + val logoImageUrl: String? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt new file mode 100644 index 00000000..b080e817 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/collection/CollectionViewOptionsDialog.kt @@ -0,0 +1,174 @@ +package com.github.damontecres.wholphin.ui.detail.collection + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +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.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsAspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsColumns +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsContentScale +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsDetailHeader +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsImageType +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsReset +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsShowTitles +import com.github.damontecres.wholphin.ui.components.ViewOptions.Companion.ViewOptionsSpacing +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.serialization.Serializable + +@Composable +fun CollectionViewOptionsDialog( + viewOptions: CollectionViewOptions, + onDismissRequest: () -> Unit, + onViewOptionsChange: (CollectionViewOptions) -> Unit, + defaultViewOptions: CollectionViewOptions = CollectionViewOptions(), +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val columnState = rememberLazyListState() + val options = + if (viewOptions.separateTypes) CollectionViewOptions.SeparateOptions else CollectionViewOptions.MixedOptions + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.END) + window.setDimAmount(0f) + } + Column( + modifier = + Modifier + .width(256.dp) + .heightIn(max = 380.dp) + .focusRequester(focusRequester) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape = RoundedCornerShape(8.dp), + ).padding(16.dp), + ) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + state = columnState, + contentPadding = PaddingValues(0.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + item { + val pref = CollectionViewOptions.SeparateTypes + val interactionSource = remember { MutableInteractionSource() } + ComposablePreference( + preference = pref, + value = viewOptions.separateTypes, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + modifier = Modifier, + onClickPreference = {}, + ) + } + items(options, key = { it.title }) { pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions.cardViewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + val newCardViewOptions = + pref.setter(viewOptions.cardViewOptions, newValue) + onViewOptionsChange.invoke(viewOptions.copy(cardViewOptions = newCardViewOptions)) + }, + interactionSource = interactionSource, + modifier = Modifier.animateItem(), + onClickPreference = { pref -> + if (pref == ViewOptionsReset) { + onViewOptionsChange.invoke(defaultViewOptions) + } + }, + ) + } + } + } + } +} + +@Serializable +data class CollectionViewOptions( + val separateTypes: Boolean = true, + val cardViewOptions: ViewOptions = + ViewOptions( + showDetails = true, + showTitles = true, + ), +) { + companion object { + val SeparateTypes = + AppSwitchPreference( + title = R.string.separate_types, + defaultValue = false, + getter = { it.separateTypes }, + setter = { vo, value -> vo.copy(separateTypes = value) }, + ) + + val MixedOptions = + listOf( + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsColumns, + ViewOptionsSpacing, + ViewOptionsContentScale, + ViewOptionsReset, + ) + + val SeparateOptions = + listOf( + ViewOptionsDetailHeader, + ViewOptionsShowTitles, + ViewOptionsReset, + ) + } +} 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 3a7a457a..733ddef2 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 @@ -227,6 +227,15 @@ fun HomePageContent( listState: LazyListState = rememberLazyListState(), takeFocus: Boolean = true, showEmptyRows: Boolean = false, + headerComposable: @Composable (focusedItem: BaseItem?) -> Unit = { focusedItem -> + HomePageHeader( + item = focusedItem, + modifier = + Modifier + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) + .fillMaxHeight(.33f), + ) + }, ) { val focusedItem = position.let { @@ -266,13 +275,8 @@ fun HomePageContent( } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { - HomePageHeader( - item = focusedItem, - modifier = - Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) - .fillMaxHeight(.33f), - ) + headerComposable.invoke(focusedItem) + val density = LocalDensity.current val spaceAbovePx = with(density) { 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 acbcb552..7c41c6d1 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 @@ -11,7 +11,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo import com.github.damontecres.wholphin.ui.data.MovieSortOptions -import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie @@ -24,6 +23,7 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.collection.CollectionDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails @@ -146,11 +146,9 @@ fun DestinationContent( BaseItemKind.BOX_SET -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } - CollectionFolderBoxSet( + CollectionDetails( preferences = preferences, itemId = destination.itemId, - recursive = false, - playEnabled = true, modifier = modifier, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee667101..fc71cd52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -746,5 +746,6 @@ Visualizing the currently playing audio requires permission to record audio. Continue Select all + Separate types diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 39705dd3..6ebdde41 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig @@ -278,5 +281,14 @@ object TestDatabaseModule { produceNewData = { AppPreferences.getDefaultInstance() }, ), ) + + @Provides + @Singleton + fun keyValueDataStore( + @ApplicationContext context: Context, + ): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("key_value") }, + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa197566..b7337845 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,6 +72,7 @@ androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecyc androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }