mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Better collection pages including more view options (#1137)
## Description This is a large update to the collection page! - Show the collection's details including overview & backdrop - Separate collection by type (Movies, TV, etc) into rows - View options are now shared across all collections - Filter & sort are still saved per collection By default, the collection will be separated by type, but there is a view option to restore the combined grid. ### Related issues Closes #527 Closes #1088 Fixes https://github.com/damontecres/Wholphin/issues/877#issuecomment-3889013124 Related to #737 ### Testing Emulator mostly ## Screenshots  ### Separated types into rows  ### Combined type into grid  ## AI or LLM usage None
This commit is contained in:
parent
b8a9cb5027
commit
c35b5cdd0a
21 changed files with 1853 additions and 59 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<LibraryDisplayInfo?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun saveItem(item: LibraryDisplayInfo): Long
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Preferences>,
|
||||
) {
|
||||
val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
inline fun <reified T> get(key: String): Flow<T?> =
|
||||
dataStore.data.map { preferences ->
|
||||
preferences[stringPreferencesKey(key)]?.let {
|
||||
json.decodeFromString<T>(serializer<T>(), it)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> get(
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
): Flow<T> =
|
||||
dataStore.data.map { preferences ->
|
||||
preferences[stringPreferencesKey(key)]?.let {
|
||||
json.decodeFromString<T>(serializer<T>(), it)
|
||||
} ?: defaultValue
|
||||
}
|
||||
|
||||
inline fun <reified T> get(
|
||||
userId: UUID,
|
||||
key: String,
|
||||
defaultValue: T,
|
||||
): Flow<T> =
|
||||
dataStore.data.map { preferences ->
|
||||
preferences[stringPreferencesKey("${userId}_$key")]?.let {
|
||||
json.decodeFromString<T>(serializer<T>(), it)
|
||||
} ?: defaultValue
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> save(
|
||||
key: String,
|
||||
value: T,
|
||||
) {
|
||||
dataStore.updateData { preferences ->
|
||||
val valueStr = json.encodeToString(value)
|
||||
preferences.toMutablePreferences().apply {
|
||||
set(stringPreferencesKey(key), valueStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <reified T> save(
|
||||
userId: UUID,
|
||||
key: String,
|
||||
value: T,
|
||||
) {
|
||||
dataStore.updateData { preferences ->
|
||||
val valueStr = json.encodeToString(value)
|
||||
preferences.toMutablePreferences().apply {
|
||||
set(stringPreferencesKey("${userId}_$key"), valueStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <T> Flow<T>.collectLatestIn(
|
||||
scope: CoroutineScope,
|
||||
action: suspend (value: T) -> Unit,
|
||||
) {
|
||||
scope.launchDefault { this@collectLatestIn.collectLatest(action) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Easy way to combine two flows into a [Pair]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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<FilterValueOption>,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CollectionViewModel, CollectionViewModel.Factory>(
|
||||
creationCallback = { it.create(itemId) },
|
||||
),
|
||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
// Dialogs
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showDeleteDialog by remember { mutableStateOf<Pair<RowColumn?, BaseItem>?>(null) }
|
||||
var showViewOptionsDialog by remember { mutableStateOf(false) }
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(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<FilterValueOption>,
|
||||
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<BaseItem?>(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<DialogItem> =
|
||||
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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CollectionState> = _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<BaseItemKind>,
|
||||
): ApiRequestPager<GetItemsRequest> {
|
||||
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<BaseItemKind>,
|
||||
): GetItemsRequest {
|
||||
val includeItemTypes: List<BaseItemKind>?
|
||||
val excludeItemTypes: List<BaseItemKind>?
|
||||
// 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<FilterValueOption> =
|
||||
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<BaseItem?> = emptyList(),
|
||||
val separateItems: Map<BaseItemKind, HomeRowLoadingState> = emptyMap(),
|
||||
val logoImageUrl: String? = null,
|
||||
)
|
||||
|
|
@ -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<ViewOptions, Any>
|
||||
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<CollectionViewOptions>(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -746,5 +746,6 @@
|
|||
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
|
||||
<string name="continue_string">Continue</string>
|
||||
<string name="select_all">Select all</string>
|
||||
<string name="separate_types">Separate types</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { context.preferencesDataStoreFile("key_value") },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue