mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Simplify grid focus, handle items w/ missing images
This commit is contained in:
parent
2212e32c53
commit
b63e933386
13 changed files with 352 additions and 195 deletions
|
|
@ -103,6 +103,7 @@ android {
|
|||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ data class BaseItem(
|
|||
} else {
|
||||
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
|
||||
}
|
||||
} else if (dto.imageTags == null || dto.imageTags!![ImageType.PRIMARY] == null) {
|
||||
// TODO is this a bad assumption?
|
||||
null
|
||||
} else {
|
||||
api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ sealed class AppColors private constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
const val DEFAULT_PAGE_SIZE = 50
|
||||
const val DEFAULT_PAGE_SIZE = 100
|
||||
|
||||
val DefaultItemFields =
|
||||
listOf(
|
||||
|
|
|
|||
|
|
@ -13,11 +13,16 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
|
|
@ -30,6 +35,7 @@ import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
|||
|
||||
@Composable
|
||||
fun BannerCard(
|
||||
name: String?,
|
||||
imageUrl: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
|
|
@ -41,19 +47,39 @@ fun BannerCard(
|
|||
aspectRatio: Float = 16f / 9,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
) {
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
modifier = modifier.size(cardHeight * aspectRatio, cardHeight),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
if (!imageError && imageUrl.isNotNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
onError = { imageError = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = name ?: "",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
if (played || cornerText.isNotNullOrBlank()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
package com.github.damontecres.dolphin.ui.cards
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.ui.enableMarquee
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun GridCard(
|
||||
item: BaseItem?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val dto = item?.data
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
|
||||
var focusedAfterDelay by remember { mutableStateOf(false) }
|
||||
|
||||
val hideOverlayDelay = 500L
|
||||
if (focused) {
|
||||
LaunchedEffect(Unit) {
|
||||
delay(hideOverlayDelay)
|
||||
if (focused) {
|
||||
focusedAfterDelay = true
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
focusedAfterDelay = false
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
colors =
|
||||
CardDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
),
|
||||
) {
|
||||
ItemCardImage(
|
||||
imageUrl = item?.imageUrl,
|
||||
name = item?.name,
|
||||
// showOverlay = !focusedAfterDelay,
|
||||
showOverlay = true,
|
||||
favorite = dto?.userData?.isFavorite ?: false,
|
||||
watched = dto?.userData?.played ?: false,
|
||||
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
|
||||
watchedPercent = dto?.userData?.playedPercentage,
|
||||
useFallbackText = false,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(2f / 3f) // TODO
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
modifier = Modifier.padding(bottom = spaceBelow).fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = item?.name ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
Text(
|
||||
text = item?.data?.productionYear?.toString() ?: "",
|
||||
maxLines = 1,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp)
|
||||
.enableMarquee(focusedAfterDelay),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.dolphin.ui.cards
|
|||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
|
|
@ -27,10 +28,13 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
|
|
@ -47,6 +51,7 @@ import com.github.damontecres.dolphin.data.model.BaseItem
|
|||
import com.github.damontecres.dolphin.ui.FontAwesome
|
||||
import com.github.damontecres.dolphin.ui.enableMarquee
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.dolphin.util.seasonEpisode
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
|
|
@ -161,24 +166,64 @@ fun ItemCardImage(
|
|||
unwatchedCount: Int,
|
||||
watchedPercent: Double?,
|
||||
modifier: Modifier = Modifier,
|
||||
useFallbackText: Boolean = true,
|
||||
) {
|
||||
var imageError by remember { mutableStateOf(false) }
|
||||
Box(modifier = modifier) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
// TODO error/fallback images
|
||||
error = null,
|
||||
fallback = null,
|
||||
onError = {
|
||||
Timber.e(it.result.throwable, "Error loading image: $imageUrl")
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopCenter),
|
||||
)
|
||||
if (!imageError && imageUrl.isNotNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
// TODO error/fallback images
|
||||
error = null,
|
||||
fallback = null,
|
||||
onError = {
|
||||
Timber.e(it.result.throwable, "Error loading image: $imageUrl")
|
||||
imageError = true
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopCenter),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopCenter),
|
||||
) {
|
||||
if (useFallbackText && name.isNotNullOrBlank()) {
|
||||
Text(
|
||||
text = name,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.video_solid),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter =
|
||||
ColorFilter.tint(
|
||||
MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
BlendMode.SrcIn,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize(.4f)
|
||||
.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showOverlay,
|
||||
enter = fadeIn(),
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fun BannerItemRow(
|
|||
modifier = modifier,
|
||||
cardContent = { index, item, modifier, onClick, onLongClick ->
|
||||
BannerCard(
|
||||
name = title,
|
||||
imageUrl = item?.imageUrl,
|
||||
aspectRatio =
|
||||
aspectRatioOverride ?: item?.data?.primaryImageAspectRatio?.toFloat() ?: (16f / 9),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ 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
|
||||
|
|
@ -44,7 +43,7 @@ import com.github.damontecres.dolphin.R
|
|||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
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.cards.GridCard
|
||||
import com.github.damontecres.dolphin.ui.ifElse
|
||||
import com.github.damontecres.dolphin.ui.playback.isBackwardButton
|
||||
import com.github.damontecres.dolphin.ui.playback.isForwardButton
|
||||
|
|
@ -53,7 +52,6 @@ import com.github.damontecres.dolphin.ui.tryRequestFocus
|
|||
import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlin.math.max
|
||||
|
||||
private const val DEBUG = false
|
||||
|
||||
|
|
@ -63,7 +61,6 @@ fun CardGrid(
|
|||
itemOnClick: (BaseItem) -> Unit,
|
||||
longClicker: (BaseItem) -> Unit,
|
||||
letterPosition: suspend (Char) -> Int,
|
||||
requestFocus: Boolean,
|
||||
gridFocusRequester: FocusRequester,
|
||||
showJumpButtons: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -79,45 +76,6 @@ fun CardGrid(
|
|||
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(
|
||||
"Grid: 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..<pager.size) {
|
||||
// If this is a recomposition, but not the first
|
||||
// focus on the restored index
|
||||
// gridState.scrollToItem(index, -columns)
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
savedFocusedIndex = -1
|
||||
}
|
||||
// hasRun = true
|
||||
}
|
||||
|
||||
var alphabetFocus by remember { mutableStateOf(false) }
|
||||
val focusOn = { index: Int ->
|
||||
|
|
@ -129,12 +87,12 @@ fun CardGrid(
|
|||
}
|
||||
|
||||
// Wait for a recomposition to focus
|
||||
LaunchedEffect(alphabetFocus) {
|
||||
if (alphabetFocus) {
|
||||
firstFocus.tryRequestFocus()
|
||||
}
|
||||
alphabetFocus = false
|
||||
}
|
||||
// LaunchedEffect(alphabetFocus) {
|
||||
// if (alphabetFocus) {
|
||||
// firstFocus.tryRequestFocus()
|
||||
// }
|
||||
// alphabetFocus = false
|
||||
// }
|
||||
|
||||
val useBackToJump = true // uiConfig.preferences.interfacePreferences.scrollTopOnBack
|
||||
val showFooter = true // uiConfig.preferences.interfacePreferences.showPositionFooter
|
||||
|
|
@ -169,7 +127,6 @@ fun CardGrid(
|
|||
val newPosition =
|
||||
(gridState.firstVisibleItemIndex + jump).coerceIn(0..<pager.size)
|
||||
if (DEBUG) Timber.d("newPosition=$newPosition")
|
||||
savedFocusedIndex = newPosition
|
||||
focusOn(newPosition)
|
||||
gridState.scrollToItem(newPosition, 0)
|
||||
}
|
||||
|
|
@ -255,51 +212,38 @@ fun CardGrid(
|
|||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.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) {
|
||||
Timber.d("onEnter: focusedIndex=$focusedIndex, savedFocusedIndex=$savedFocusedIndex")
|
||||
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 ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier.focusRequester(firstFocus)
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
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",
|
||||
)
|
||||
GridCard(
|
||||
item = item,
|
||||
onClick = {
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
itemOnClick.invoke(item)
|
||||
}
|
||||
// focus on startPosition
|
||||
gridState.scrollToItem(startPosition, 0)
|
||||
firstFocus.tryRequestFocus()
|
||||
hasRequestFocusRun = true
|
||||
}
|
||||
}
|
||||
ItemCard(
|
||||
},
|
||||
onLongClick = { if (item != null) longClicker.invoke(item) },
|
||||
modifier =
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
|
|
@ -314,20 +258,11 @@ fun CardGrid(
|
|||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
savedFocusedIndex = index
|
||||
// Was focused on this, so mark unfocused
|
||||
focusedIndex = -1
|
||||
// savedFocusedIndex = index
|
||||
// // Was focused on this, so mark unfocused
|
||||
// focusedIndex = -1
|
||||
}
|
||||
},
|
||||
item = item,
|
||||
onClick = {
|
||||
if (item != null) {
|
||||
itemOnClick.invoke(item)
|
||||
savedFocusedIndex = index
|
||||
}
|
||||
},
|
||||
onLongClick = { if (item != null) longClicker.invoke(item) },
|
||||
cardHeight = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -349,12 +284,12 @@ fun CardGrid(
|
|||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index =
|
||||
if (focusedIndex >= 0) {
|
||||
focusedIndex + 1
|
||||
} else {
|
||||
max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||
}
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
// if (focusedIndex >= 0) {
|
||||
// focusedIndex + 1
|
||||
// } else {
|
||||
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||
// }
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ fun CollectionDetails(
|
|||
},
|
||||
longClicker = {},
|
||||
letterPosition = { 0 },
|
||||
requestFocus = true,
|
||||
gridFocusRequester = gridFocusRequester,
|
||||
showJumpButtons = false, // TODO add preference
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ fun SeriesOverviewContent(
|
|||
)
|
||||
}
|
||||
BannerCard(
|
||||
name = episode?.name,
|
||||
imageUrl = episode?.imageUrl,
|
||||
aspectRatio =
|
||||
episode?.data?.primaryImageAspectRatio?.toFloat()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import androidx.tv.material3.Text
|
|||
import coil3.compose.AsyncImage
|
||||
import com.github.damontecres.dolphin.data.model.BaseItem
|
||||
import com.github.damontecres.dolphin.preferences.UserPreferences
|
||||
import com.github.damontecres.dolphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.dolphin.ui.cards.BannerCard
|
||||
import com.github.damontecres.dolphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.dolphin.ui.components.DotSeparatedRow
|
||||
|
|
@ -71,7 +70,7 @@ fun HomePage(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
) {
|
||||
OneTimeLaunchedEffect {
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(preferences)
|
||||
}
|
||||
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
|
||||
|
|
@ -96,14 +95,8 @@ fun HomePageContent(
|
|||
var position by rememberSaveable(stateSaver = RowColumnSaver) {
|
||||
mutableStateOf(RowColumn(0, 0))
|
||||
}
|
||||
var focusedItem = position.let { homeRows.getOrNull(it.row)?.items?.getOrNull(it.column) }
|
||||
|
||||
var focusedItem by remember {
|
||||
mutableStateOf<BaseItem?>(
|
||||
homeRows.getOrNull(0)?.items?.getOrNull(
|
||||
0,
|
||||
),
|
||||
)
|
||||
}
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val positionFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -181,6 +174,7 @@ fun HomePageContent(
|
|||
cardContent = { index, item, cardModifier, onClick, onLongClick ->
|
||||
// TODO better aspect ration handling?
|
||||
BannerCard(
|
||||
name = item?.data?.seriesName ?: item?.name,
|
||||
imageUrl = item?.imageUrl,
|
||||
aspectRatio = (2f / 3f),
|
||||
cornerText = item?.data?.indexNumber?.let { "E$it" },
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
|||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.UserDto
|
||||
import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -44,6 +46,7 @@ class HomeViewModel
|
|||
"Error loading home page",
|
||||
),
|
||||
) {
|
||||
Timber.d("init HomeViewModel")
|
||||
val user by api.userApi.getCurrentUser()
|
||||
// val displayPrefs =
|
||||
// api.displayPreferencesApi
|
||||
|
|
@ -72,68 +75,17 @@ class HomeViewModel
|
|||
// )
|
||||
// }
|
||||
|
||||
val latestMediaIncludes =
|
||||
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
|
||||
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
|
||||
}
|
||||
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
|
||||
val homeRows =
|
||||
homeSections
|
||||
.mapNotNull { section ->
|
||||
Timber.Forest.v("Loading section: %s", section.name)
|
||||
when (section) {
|
||||
HomeSection.LATEST_MEDIA -> {
|
||||
latestMediaIncludes.mapNotNull { viewId ->
|
||||
val view =
|
||||
views.items
|
||||
.firstOrNull { it.id == viewId }
|
||||
if (view?.collectionType in supportedCollectionTypes) {
|
||||
val title =
|
||||
view
|
||||
?.name
|
||||
?.let {
|
||||
"Recently Added in $it"
|
||||
}
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = DefaultItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = viewId,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
)
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
HomeRow(
|
||||
section = section,
|
||||
items = latest,
|
||||
title = title,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
getLatest(user, limit)
|
||||
}
|
||||
|
||||
HomeSection.RESUME -> {
|
||||
val request =
|
||||
GetResumeItemsRequest(
|
||||
userId = user.id,
|
||||
fields = DefaultItemFields,
|
||||
limit = limit,
|
||||
includeItemTypes = supportItemKinds,
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
.getResumeItems(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.Companion.from(it, api, true) }
|
||||
val items = getResume(user.id, limit)
|
||||
listOf(
|
||||
HomeRow(
|
||||
section = section,
|
||||
|
|
@ -143,22 +95,12 @@ class HomeViewModel
|
|||
}
|
||||
|
||||
HomeSection.NEXT_UP -> {
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
fields = DefaultItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
limit = limit,
|
||||
enableResumable = false,
|
||||
enableUserData = true,
|
||||
enableRewatching = preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.Companion.from(it, api, true) }
|
||||
getNextUp(
|
||||
user.id,
|
||||
limit,
|
||||
preferences.appPreferences.homePagePreferences.enableRewatchingNextUp,
|
||||
)
|
||||
listOf(
|
||||
HomeRow(
|
||||
section = section,
|
||||
|
|
@ -186,4 +128,92 @@ class HomeViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getResume(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = DefaultItemFields,
|
||||
limit = limit,
|
||||
includeItemTypes = supportItemKinds,
|
||||
)
|
||||
val items =
|
||||
api.itemsApi
|
||||
.getResumeItems(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.Companion.from(it, api, true) }
|
||||
return items
|
||||
}
|
||||
|
||||
private suspend fun getNextUp(
|
||||
userId: UUID,
|
||||
limit: Int,
|
||||
enableRewatching: Boolean,
|
||||
): List<BaseItem> {
|
||||
val request =
|
||||
GetNextUpRequest(
|
||||
userId = userId,
|
||||
fields = DefaultItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = null,
|
||||
limit = limit,
|
||||
enableResumable = false,
|
||||
enableUserData = true,
|
||||
enableRewatching = enableRewatching,
|
||||
)
|
||||
val nextUp =
|
||||
api.tvShowsApi
|
||||
.getNextUp(request)
|
||||
.content
|
||||
.items
|
||||
.map { BaseItem.Companion.from(it, api, true) }
|
||||
return nextUp
|
||||
}
|
||||
|
||||
private suspend fun getLatest(
|
||||
user: UserDto,
|
||||
limit: Int,
|
||||
): List<HomeRow> {
|
||||
val latestMediaIncludes =
|
||||
user.configuration?.orderedViews.orEmpty().toMutableList().apply {
|
||||
removeAll(user.configuration?.latestItemsExcludes.orEmpty())
|
||||
}
|
||||
|
||||
val views by api.userViewsApi.getUserViews()
|
||||
val rows =
|
||||
latestMediaIncludes
|
||||
.mapNotNull { viewId -> views.items.firstOrNull { it.id == viewId } }
|
||||
.filter { it.collectionType in supportedCollectionTypes }
|
||||
.map { view ->
|
||||
val title =
|
||||
view
|
||||
?.name
|
||||
?.let {
|
||||
"Recently Added in $it"
|
||||
}
|
||||
val request =
|
||||
GetLatestMediaRequest(
|
||||
fields = DefaultItemFields,
|
||||
imageTypeLimit = 1,
|
||||
parentId = view.id,
|
||||
groupItems = true,
|
||||
limit = limit,
|
||||
)
|
||||
val latest =
|
||||
api.userLibraryApi
|
||||
.getLatestMedia(request)
|
||||
.content
|
||||
.map { BaseItem.from(it, api, true) }
|
||||
HomeRow(
|
||||
section = HomeSection.LATEST_MEDIA,
|
||||
items = latest,
|
||||
title = title,
|
||||
)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
app/src/main/res/drawable/video_solid.xml
Normal file
5
app/src/main/res/drawable/video_solid.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="455dp" android:viewportHeight="512" android:viewportWidth="576" android:width="512dp">
|
||||
|
||||
<path android:fillColor="#FF000000" android:pathData="M0,128C0,92.7 28.7,64 64,64l256,0c35.3,0 64,28.7 64,64l0,256c0,35.3 -28.7,64 -64,64L64,448c-35.3,0 -64,-28.7 -64,-64L0,128zM559.1,99.8c10.4,5.6 16.9,16.4 16.9,28.2l0,256c0,11.8 -6.5,22.6 -16.9,28.2s-23,5 -32.9,-1.6l-96,-64L416,337.1l0,-17.1 0,-128 0,-17.1 14.2,-9.5 96,-64c9.8,-6.5 22.4,-7.2 32.9,-1.6z"/>
|
||||
|
||||
</vector>
|
||||
Loading…
Add table
Add a link
Reference in a new issue