From 6e185b25b7493d365aaa2fb631d83042a8749826 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Sep 2025 16:14:01 -0400 Subject: [PATCH] Add grid --- .../github/damontecres/dolphin/Extensions.kt | 23 + .../dolphin/ui/cards/DolphinCard.kt | 11 +- .../damontecres/dolphin/ui/cards/ItemCard.kt | 56 +++ .../damontecres/dolphin/ui/cards/NullCard.kt | 30 ++ .../damontecres/dolphin/ui/cards/VideoCard.kt | 20 +- .../damontecres/dolphin/ui/detail/CardGrid.kt | 445 ++++++++++++++++++ .../ui/detail/CollectionFolderDetails.kt | 138 ++++++ .../dolphin/ui/detail/EpisodeDetails.kt | 36 +- .../dolphin/ui/detail/ItemViewModel.kt | 45 ++ .../dolphin/ui/detail/MediaItemContent.kt | 8 + .../damontecres/dolphin/ui/main/MainPage.kt | 20 +- .../damontecres/dolphin/ui/nav/NavDrawer.kt | 3 +- .../dolphin/ui/playback/KeyIdentifier.kt | 7 + .../damontecres/dolphin/util/DolphinPager.kt | 128 +++++ app/src/main/res/values/fa_strings.xml | 29 ++ 15 files changed, 955 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/ui/detail/ItemViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/dolphin/util/DolphinPager.kt create mode 100644 app/src/main/res/values/fa_strings.xml diff --git a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt index 3e2ae086..2942d8b0 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/Extensions.kt @@ -1,9 +1,16 @@ package com.github.damontecres.dolphin import android.view.KeyEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.input.key.onPreviewKeyEvent +import kotlinx.coroutines.CoroutineScope import timber.log.Timber import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -85,3 +92,19 @@ fun Modifier.handleDPadKeyEvents( false } + +/** + * Run a [LaunchedEffect] exactly once even with multiple recompositions. + * + * If the composition is removed from the navigation back stack and "re-added", this will run again + */ +@Composable +fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { + var hasRun by rememberSaveable { mutableStateOf(false) } + if (!hasRun) { + LaunchedEffect(Unit) { + hasRun = true + runOnceBlock.invoke(this) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/DolphinCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/DolphinCard.kt index 48232be7..8ec6a253 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/DolphinCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/DolphinCard.kt @@ -2,13 +2,14 @@ package com.github.damontecres.dolphin.ui.cards import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.github.damontecres.dolphin.data.model.DolphinModel +import androidx.tv.material3.Text import com.github.damontecres.dolphin.data.model.Library import com.github.damontecres.dolphin.data.model.Video +import org.jellyfin.sdk.model.api.BaseItemDto @Composable fun DolphinCard( - item: DolphinModel?, + item: Any?, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -21,6 +22,10 @@ fun DolphinCard( ) is Library -> TODO() - null -> TODO() + is BaseItemDto -> { + Text(item.id.toString()) + } + + null -> NullCard(modifier = modifier) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt new file mode 100644 index 00000000..cba389d1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/ItemCard.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.dolphin.ui.cards + +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.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import org.jellyfin.sdk.model.api.BaseItemDto +import java.util.UUID + +@Composable +fun ItemCard( + item: BaseItemDto?, + imageUrlBuilder: (UUID) -> String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 150.dp, + cardHeight: Dp = 200.dp, +) { + if (item == null) { + NullCard(modifier, cardWidth, cardHeight) + } else { + Card( + modifier = modifier, + onClick = onClick, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.width(cardWidth), + ) { + AsyncImage( + model = imageUrlBuilder.invoke(item.id), + contentDescription = item.name, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxWidth() + .height(cardHeight), + ) + Text( + text = item.name ?: "", + ) + Text(text = item.primaryImageAspectRatio.toString()) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt new file mode 100644 index 00000000..a2d4ebd8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/NullCard.kt @@ -0,0 +1,30 @@ +package com.github.damontecres.dolphin.ui.cards + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.Text + +@Composable +fun NullCard( + modifier: Modifier = Modifier, + cardWidth: Dp = 150.dp, + cardHeight: Dp = 200.dp, +) { + Card( + modifier = modifier, + onClick = {}, + ) { + Column( + modifier = Modifier.size(cardWidth, cardHeight), + ) { + Text( + text = "Loading...", + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/VideoCard.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/VideoCard.kt index 4eb4e706..4ef86e02 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/cards/VideoCard.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/cards/VideoCard.kt @@ -1,9 +1,13 @@ package com.github.damontecres.dolphin.ui.cards +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card @@ -24,14 +28,20 @@ fun VideoCard( onClick = onClick, ) { Column( - modifier = Modifier.size(cardWidth, cardHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.width(cardWidth), ) { - Text( - text = item.name ?: "", - ) AsyncImage( model = item.imageUrl, contentDescription = item.name, + contentScale = ContentScale.Fit, + modifier = + Modifier + .fillMaxWidth() + .height(cardHeight), + ) + Text( + text = item.name ?: "", ) } } diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt new file mode 100644 index 00000000..5760c986 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CardGrid.kt @@ -0,0 +1,445 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.R +import com.github.damontecres.dolphin.ifElse +import com.github.damontecres.dolphin.tryRequestFocus +import com.github.damontecres.dolphin.ui.AppColors +import com.github.damontecres.dolphin.ui.FontAwesome +import com.github.damontecres.dolphin.ui.cards.ItemCard +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.ui.playback.isBackwardButton +import com.github.damontecres.dolphin.ui.playback.isForwardButton +import com.github.damontecres.dolphin.ui.playback.isPlayKeyUp +import com.github.damontecres.dolphin.util.DolphinPager +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber +import kotlin.math.max + +private val DEBUG = false + +@Composable +fun CardGrid( + api: ApiClient, + pager: DolphinPager, + itemOnClick: (BaseItemDto) -> Unit, + longClicker: (BaseItemDto) -> Unit, + letterPosition: suspend (Char) -> Int, + requestFocus: Boolean, + gridFocusRequester: FocusRequester, + navigationManager: NavigationManager, + modifier: Modifier = Modifier, + initialPosition: Int = 0, + positionCallback: ((columns: Int, position: Int) -> Unit)? = null, +) { + val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) + val columns = 5 + + val gridState = rememberLazyGridState() + val scope = rememberCoroutineScope() + val firstFocus = remember { FocusRequester() } + val zeroFocus = remember { FocusRequester() } + var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } + var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + var focusedIndexOnExit by rememberSaveable { mutableIntStateOf(-1) } + + // Tracks whether the very first requestFocus has run, if the caller isn't requesting focus, + // then the first time will never run + var hasRequestFocusRun by rememberSaveable { mutableStateOf(!requestFocus) } + var savedFocusedIndex by rememberSaveable { mutableIntStateOf(-1) } + + if (DEBUG) { + Timber.d( + "StashGrid: hasRun=$hasRequestFocusRun, requestFocus=$requestFocus, initialPosition=$initialPosition, focusedIndex=$focusedIndex", + ) + } + + LaunchedEffect(Unit) { + if (!hasRequestFocusRun) { + // On very first composition, if parent wants to focus on the grid, scroll to the item + if (requestFocus && initialPosition >= 0) { + if (DEBUG) { + Timber.d( + "focus on startPosition=$startPosition, from initialPosition=$initialPosition", + ) + } + focusedIndex = startPosition + gridState.scrollToItem(startPosition, 0) + firstFocus.tryRequestFocus() + } + } else { + val index = savedFocusedIndex + if (DEBUG) Timber.d("savedFocusedIndex=$index") + if (index in 0.. + if (DEBUG) Timber.v("focusOn: focusedIndex=$focusedIndex, index=$index") + if (index != focusedIndex) { + previouslyFocusedIndex = focusedIndex + } + focusedIndex = index + } + + // Wait for a recomposition to focus + LaunchedEffect(alphabetFocus) { + if (alphabetFocus) { + firstFocus.tryRequestFocus() + } + alphabetFocus = false + } + + val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack + val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter + val useJumpRemoteButtons = true // uiConfig.preferences.interfacePreferences.pageWithRemoteButtons + val jump2 = + remember { + if (pager.size >= 25_000) { + columns * 2000 + } else if (pager.size >= 7_000) { + columns * 200 + } else if (pager.size >= 2_000) { + columns * 50 + } else { + columns * 20 + } + } + val jump1 = + remember { + if (pager.size >= 25_000) { + columns * 500 + } else if (pager.size >= 7_000) { + columns * 50 + } else if (pager.size >= 2_000) { + columns * 15 + } else { + columns * 6 + } + } + + val jump = { jump: Int -> + scope.launch { + val newPosition = + (gridState.firstVisibleItemIndex + jump).coerceIn(0.. 0) { + focusOn(newPosition) + scope.launch { + gridState.scrollToItem(newPosition, -columns) + firstFocus.tryRequestFocus() + } + } + return@onKeyEvent true + } else if (it.type == KeyEventType.KeyUp) { + if (longPressing && it.key == Key.Back) { + longPressing = false + return@onKeyEvent true + } + longPressing = false + } + if (it.type != KeyEventType.KeyUp) { + return@onKeyEvent false + } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { + jumpToTop() + return@onKeyEvent true + } else if (isPlayKeyUp(it)) { + // TODO play the focused item + return@onKeyEvent true + } else if (useJumpRemoteButtons && isForwardButton(it)) { + jump(jump1) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isBackwardButton(it)) { + jump(-jump1) + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }, + ) { + if (showJumpButtons && pager.size > 0) { + JumpButtons( + jump1 = jump1, + jump2 = jump2, + jumpClick = { jump(it) }, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + Box( + modifier = Modifier.weight(1f), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + state = gridState, + contentPadding = PaddingValues(16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRequester(gridFocusRequester) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position + focusedIndexOnExit = focusedIndex + focusedIndex = -1 + savedFocusedIndex = -1 + } + onEnter = { + focusedIndexOnExit = -1 + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + firstFocus.tryRequestFocus() + } + } + }, + ) { + items(pager.size) { index -> + val mod = + if (index == savedFocusedIndex) { + if (DEBUG) Timber.d("Adding firstFocus to itemClickedIndex $index") + Modifier.focusRequester(firstFocus) + } else if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + // TODO + val item = pager[index] // ?.let { convertModel(it, api) } + if (!hasRequestFocusRun && requestFocus && initialPosition >= 0) { + // On very first composition, if parent wants to focus on the grid, do so + LaunchedEffect(Unit) { + if (DEBUG) { + Timber.d( + "non-null focus on startPosition=$startPosition, from initialPosition=$initialPosition", + ) + } + // focus on startPosition + gridState.scrollToItem(startPosition, 0) + firstFocus.tryRequestFocus() + hasRequestFocusRun = true + } + } + ItemCard( + modifier = + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { + savedFocusedIndex = index + // Was focused on this, so mark unfocused + focusedIndex = -1 + } + }, + item = item, + onClick = { if (item != null) itemOnClick.invoke(item) }, + onLongClick = { if (item != null) longClicker.invoke(item) }, + imageUrlBuilder = { api.imageApi.getItemImageUrl(it, ImageType.PRIMARY) }, + ) + } + } + if (pager.size == 0) { +// focusedIndex = -1 + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = "No results", + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } + } + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = + if (focusedIndex >= 0) { + focusedIndex + 1 + } else { + max(savedFocusedIndex, focusedIndexOnExit) + 1 + } + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } + } + } + // Letters + if (pager.isNotEmpty() && false) { + // TODO + AlphabetButtons( + modifier = Modifier.align(Alignment.CenterVertically), + letterClicked = { letter -> + scope.launch { + val jumpPosition = letterPosition.invoke(letter) + Timber.d("Alphabet jump to $jumpPosition") + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true +// firstFocus.tryRequestFocus() + } + }, + ) + } + } +} + +@Composable +fun JumpButtons( + jump1: Int, + jump2: Int, + jumpClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + JumpButton(R.string.fa_angles_up, -jump2, jumpClick) + JumpButton(R.string.fa_angle_up, -jump1, jumpClick) + JumpButton(R.string.fa_angle_down, jump1, jumpClick) + JumpButton(R.string.fa_angles_down, jump2, jumpClick) + } +} + +@Composable +fun JumpButton( + @StringRes stringRes: Int, + jumpAmount: Int, + jumpClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Button( + modifier = modifier.width(40.dp), + contentPadding = PaddingValues(4.dp), + onClick = { + jumpClick.invoke(jumpAmount) + }, + ) { + Text(text = stringResource(stringRes), fontFamily = FontAwesome) + } +} + +@Composable +fun AlphabetButtons( + letterClicked: (Char) -> Unit, + modifier: Modifier = Modifier, +) { +// LazyColumn(modifier = modifier) { +// items( +// AlphabetSearchUtils.LETTERS.length, +// key = { AlphabetSearchUtils.LETTERS[it] }, +// ) { index -> +// Button( +// modifier = +// Modifier.size(24.dp), +// contentPadding = PaddingValues(2.dp), +// onClick = { +// letterClicked.invoke(AlphabetSearchUtils.LETTERS[index]) +// }, +// ) { +// Text(text = AlphabetSearchUtils.LETTERS[index].toString()) +// } +// } +// } +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt new file mode 100644 index 00000000..44dc1957 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/CollectionFolderDetails.kt @@ -0,0 +1,138 @@ +package com.github.damontecres.dolphin.ui.detail + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Text +import com.github.damontecres.dolphin.OneTimeLaunchedEffect +import com.github.damontecres.dolphin.data.model.Library +import com.github.damontecres.dolphin.preferences.UserPreferences +import com.github.damontecres.dolphin.ui.nav.Destination +import com.github.damontecres.dolphin.ui.nav.NavigationManager +import com.github.damontecres.dolphin.util.DolphinPager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class CollectionFolderViewModel + @Inject + constructor( + api: ApiClient, + ) : ItemViewModel(api) { + val pager = MutableLiveData() + + override fun init( + itemId: UUID, + potential: BaseItemDto?, + ): Job = + viewModelScope.launch { + super.init(itemId, potential)?.join() + setup() + } + + private suspend fun setup() { + if (!pager.isInitialized) { + item.value?.let { item -> + val request = + GetItemsRequest( + parentId = item.id, + isSeries = true, + mediaTypes = null, +// recursive = true, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = listOf(BaseItemKind.SERIES), + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO), + ) + val newPager = DolphinPager(api, request, viewModelScope) + newPager.init() + pager.value = newPager + } + } + } + } + +@Composable +fun CollectionFolderDetails( + preferences: UserPreferences, + navigationManager: NavigationManager, + destination: Destination.MediaItem, + modifier: Modifier = Modifier, + viewModel: CollectionFolderViewModel = hiltViewModel(), +) { + OneTimeLaunchedEffect { + viewModel.init(destination.itemId, destination.item) + } + val item by viewModel.item.observeAsState() + val library by viewModel.model.observeAsState() + val pager by viewModel.pager.observeAsState() + if (library == null) { + Text("Loading library...") + } else { + pager?.let { pager -> + when (library!!.collectionType) { + CollectionType.UNKNOWN -> TODO() + CollectionType.MOVIES -> TODO() + CollectionType.TVSHOWS -> { + TVShowCollectionDetails(viewModel.api, preferences, navigationManager, library!!, item!!, pager, modifier) + } + + CollectionType.MUSIC -> TODO() + CollectionType.MUSICVIDEOS -> TODO() + CollectionType.TRAILERS -> TODO() + CollectionType.HOMEVIDEOS -> TODO() + CollectionType.BOXSETS -> TODO() + CollectionType.BOOKS -> TODO() + CollectionType.PHOTOS -> TODO() + CollectionType.LIVETV -> TODO() + CollectionType.PLAYLISTS -> TODO() + CollectionType.FOLDERS -> TODO() + } + } + } +} + +@Composable +fun TVShowCollectionDetails( + api: ApiClient, + preferences: UserPreferences, + navigationManager: NavigationManager, + library: Library, + item: BaseItemDto, + pager: DolphinPager, + modifier: Modifier = Modifier, +) { + val gridFocusRequester = remember { FocusRequester() } + CardGrid( + api = api, + pager = pager, + itemOnClick = { navigationManager.navigateTo(Destination.MediaItem(it.id, it.type, it)) }, + longClicker = {}, + letterPosition = { 0 }, + requestFocus = true, + gridFocusRequester = gridFocusRequester, + navigationManager = navigationManager, + modifier = modifier, + initialPosition = 0, + positionCallback = { _, _ -> }, + ) +} diff --git a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt index 0ffc3dab..d4e585c4 100644 --- a/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/dolphin/ui/detail/EpisodeDetails.kt @@ -7,52 +7,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import androidx.tv.material3.Button import androidx.tv.material3.Text +import com.github.damontecres.dolphin.data.model.Video import com.github.damontecres.dolphin.preferences.UserPreferences import com.github.damontecres.dolphin.ui.nav.Destination import com.github.damontecres.dolphin.ui.nav.NavigationManager import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemDto -import timber.log.Timber -import java.util.UUID import javax.inject.Inject @HiltViewModel class EpisodeViewModel @Inject constructor( - val api: ApiClient, - ) : ViewModel() { - val item = MutableLiveData(null) - - fun init( - itemId: UUID, - potential: BaseItemDto?, - ) { - if (item.value == null && potential?.id == itemId) { - item.value = potential - return - } - if (item.value?.id == itemId) { - return - } - viewModelScope.launch { - try { - item.value = api.userLibraryApi.getItem(itemId).content - } catch (e: Exception) { - Timber.e(e, "Failed to load item $itemId") - item.value = null - } - } - } - } + api: ApiClient, + ) : ItemViewModel