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.lifecycle.livedata.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.protobuf.kotlin.lite)
|
implementation(libs.protobuf.kotlin.lite)
|
||||||
implementation(libs.androidx.tvprovider)
|
implementation(libs.androidx.tvprovider)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
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.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
|
@ -27,6 +28,12 @@ interface LibraryDisplayInfoDao {
|
||||||
itemId: String,
|
itemId: String,
|
||||||
): LibraryDisplayInfo?
|
): LibraryDisplayInfo?
|
||||||
|
|
||||||
|
@Query("SELECT * from LibraryDisplayInfo WHERE userId=:userId AND itemId=:itemId")
|
||||||
|
fun getItemAsFlow(
|
||||||
|
userId: Int,
|
||||||
|
itemId: String,
|
||||||
|
): Flow<LibraryDisplayInfo?>
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
fun saveItem(item: LibraryDisplayInfo): Long
|
fun saveItem(item: LibraryDisplayInfo): Long
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,9 @@ data class BaseItem(
|
||||||
} else if (data.premiereDate != null) {
|
} else if (data.premiereDate != null) {
|
||||||
add(data.premiereDate!!.toLocalDate().toString())
|
add(data.premiereDate!!.toLocalDate().toString())
|
||||||
}
|
}
|
||||||
|
} else if (type == BaseItemKind.BOX_SET) {
|
||||||
|
data.productionYear?.let { add(it.toString()) }
|
||||||
|
data.childCount?.let { add("$it items") }
|
||||||
} else {
|
} else {
|
||||||
data.productionYear?.let { add(it.toString()) }
|
data.productionYear?.let { add(it.toString()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import androidx.room.Ignore
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
import com.github.damontecres.wholphin.ui.components.ViewOptions
|
||||||
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
import com.github.damontecres.wholphin.ui.data.SortAndDirection
|
||||||
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.UseSerializers
|
import kotlinx.serialization.UseSerializers
|
||||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
import org.jellyfin.sdk.model.api.SortOrder
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
|
|
@ -41,4 +43,13 @@ data class LibraryDisplayInfo(
|
||||||
) {
|
) {
|
||||||
@Ignore @Transient
|
@Ignore @Transient
|
||||||
val sortAndDirection = SortAndDirection(sort, direction)
|
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.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStoreFile
|
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.room.Room
|
||||||
import com.github.damontecres.wholphin.data.AppDatabase
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
|
|
@ -85,4 +88,13 @@ object DatabaseModule {
|
||||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -437,6 +438,16 @@ fun Int?.gt(that: Int) = (this ?: 0) > that
|
||||||
|
|
||||||
fun Int?.lt(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]
|
* 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.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
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(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
takeFocus: Boolean = true,
|
takeFocus: Boolean = true,
|
||||||
showEmptyRows: Boolean = false,
|
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 =
|
val focusedItem =
|
||||||
position.let {
|
position.let {
|
||||||
|
|
@ -266,13 +275,8 @@ fun HomePageContent(
|
||||||
}
|
}
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
HomePageHeader(
|
headerComposable.invoke(focusedItem)
|
||||||
item = focusedItem,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
|
|
||||||
.fillMaxHeight(.33f),
|
|
||||||
)
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val spaceAbovePx =
|
val spaceAbovePx =
|
||||||
with(density) {
|
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.ItemGrid
|
||||||
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
import com.github.damontecres.wholphin.ui.components.LicenseInfo
|
||||||
import com.github.damontecres.wholphin.ui.data.MovieSortOptions
|
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.CollectionFolderGeneric
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv
|
||||||
import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie
|
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.FavoritesPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
import com.github.damontecres.wholphin.ui.detail.PersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDetails
|
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.DiscoverMovieDetails
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails
|
||||||
|
|
@ -146,11 +146,9 @@ fun DestinationContent(
|
||||||
|
|
||||||
BaseItemKind.BOX_SET -> {
|
BaseItemKind.BOX_SET -> {
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
CollectionFolderBoxSet(
|
CollectionDetails(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
itemId = destination.itemId,
|
itemId = destination.itemId,
|
||||||
recursive = false,
|
|
||||||
playEnabled = true,
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -746,5 +746,6 @@
|
||||||
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
|
<string name="visualizer_rationale">Visualizing the currently playing audio requires permission to record audio.</string>
|
||||||
<string name="continue_string">Continue</string>
|
<string name="continue_string">Continue</string>
|
||||||
<string name="select_all">Select all</string>
|
<string name="select_all">Select all</string>
|
||||||
|
<string name="separate_types">Separate types</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.core.DataStoreFactory
|
import androidx.datastore.core.DataStoreFactory
|
||||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
import androidx.datastore.dataStoreFile
|
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.room.Room
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.github.damontecres.wholphin.BuildConfig
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
|
@ -278,5 +281,14 @@ object TestDatabaseModule {
|
||||||
produceNewData = { AppPreferences.getDefaultInstance() },
|
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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
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-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||||
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue