diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 6504a13f..8c3a4c47 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -28,6 +29,7 @@ fun PersonRow( people: List, onClick: (Person) -> Unit, modifier: Modifier = Modifier, + @StringRes title: Int = R.string.people, onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } @@ -36,7 +38,7 @@ fun PersonRow( modifier = modifier, ) { Text( - text = stringResource(R.string.people), + text = stringResource(title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, ) @@ -50,7 +52,7 @@ fun PersonRow( .fillMaxWidth() .focusRestorer(firstFocus), ) { - itemsIndexed(people) { index, item -> + itemsIndexed(people, key = { _, item -> item.id }) { index, item -> PersonCard( item = item, onClick = { onClick.invoke(item) }, @@ -58,7 +60,8 @@ fun PersonRow( modifier = Modifier .width(120.dp) - .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + .ifElse(index == 0, Modifier.focusRequester(firstFocus)) + .animateItem(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index e21e5dc6..34a671ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -7,6 +7,7 @@ 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.FocusState import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences @@ -24,6 +25,7 @@ fun FocusedEpisodeFooter( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, modifier: Modifier = Modifier, + buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { val dto = ep.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -41,7 +43,7 @@ fun FocusedEpisodeFooter( moreOnClick = moreOnClick, watchOnClick = watchOnClick, favoriteOnClick = favoriteOnClick, - buttonOnFocusChanged = {}, + buttonOnFocusChanged = buttonOnFocusChanged, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 2582c925..30c95c95 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.data.ChosenStreams @@ -20,6 +22,7 @@ fun FocusedEpisodeHeader( ep: BaseItem?, chosenStreams: ChosenStreams?, overviewOnClick: () -> Unit, + overviewOnFocus: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -44,6 +47,7 @@ fun FocusedEpisodeHeader( overview = dto?.overview ?: "", maxLines = 3, onClick = overviewOnClick, + modifier = Modifier.onFocusChanged(overviewOnFocus), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index b1fb4ab9..104b96af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.map import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.chooseSource @@ -39,13 +40,16 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID @@ -85,6 +89,8 @@ fun SeriesOverview( val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } + val castCrewRowFocusRequester = remember { FocusRequester() } + val guestStarRowFocusRequester = remember { FocusRequester() } var initialLoadDone by rememberSaveable { mutableStateOf(false) } OneTimeLaunchedEffect { @@ -103,6 +109,7 @@ fun SeriesOverview( val series by viewModel.item.observeAsState(null) val seasons by viewModel.seasons.observeAsState(listOf()) val episodes by viewModel.episodes.observeAsState(EpisodeList.Loading) + val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) val episodeList = (episodes as? EpisodeList.Success)?.episodes var position by rememberSaveable( @@ -138,6 +145,8 @@ fun SeriesOverview( var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var rowFocused by rememberInt() + LaunchedEffect(episodes) { episodes?.let { episodes -> if (episodes is EpisodeList.Success) { @@ -152,13 +161,16 @@ fun SeriesOverview( } } - LaunchedEffect(position) { - (episodes as? EpisodeList.Success) - ?.episodes - ?.getOrNull(position.episodeRowIndex) - ?.let { - viewModel.lookUpChosenTracks(it.id, it) - } + LaunchedEffect(position, episodes) { + val focusedEpisode = + (episodes as? EpisodeList.Success) + ?.episodes + ?.getOrNull(position.episodeRowIndex) + + focusedEpisode?.let { + viewModel.lookUpChosenTracks(it.id, it) + viewModel.lookupPeopleInEpisode(it) + } } val chosenStreams by viewModel.chosenStreams.observeAsState(null) @@ -171,7 +183,14 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> - LaunchedEffect(Unit) { episodeRowFocusRequester.tryRequestFocus() } + + LaunchedEffect(Unit) { + when (rowFocused) { + EPISODE_ROW -> episodeRowFocusRequester.tryRequestFocus() + CAST_AND_CREW_ROW -> castCrewRowFocusRequester.tryRequestFocus() + GUEST_STAR_ROW -> guestStarRowFocusRequester.tryRequestFocus() + } + } LifecycleStartEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, @@ -263,6 +282,7 @@ fun SeriesOverview( seasons = seasons, episodes = episodes, chosenStreams = chosenStreams, + peopleInEpisode = peopleInEpisode, position = position, backdropImageUrl = remember { @@ -273,6 +293,8 @@ fun SeriesOverview( }, firstItemFocusRequester = firstItemFocusRequester, episodeRowFocusRequester = episodeRowFocusRequester, + castCrewRowFocusRequester = castCrewRowFocusRequester, + guestStarRowFocusRequester = guestStarRowFocusRequester, onFocus = { if (it.seasonTabIndex != position.seasonTabIndex) { seasons.getOrNull(it.seasonTabIndex)?.let { season -> @@ -282,6 +304,7 @@ fun SeriesOverview( position = it }, onClick = { + rowFocused = EPISODE_ROW val resumePosition = it.data.userData ?.playbackPositionTicks @@ -298,6 +321,7 @@ fun SeriesOverview( moreDialog = buildMoreForEpisode(ep, true) }, playOnClick = { resume -> + rowFocused = EPISODE_ROW episodeList?.getOrNull(position.episodeRowIndex)?.let { viewModel.release() viewModel.navigateTo( @@ -337,6 +361,16 @@ fun SeriesOverview( ) } }, + personOnClick = { + rowFocused = + if (it.type == PersonKind.GUEST_STAR) GUEST_STAR_ROW else CAST_AND_CREW_ROW + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, modifier = modifier, ) } @@ -391,3 +425,7 @@ fun SeriesOverview( ) } } + +private const val EPISODE_ROW = 0 +private const val CAST_AND_CREW_ROW = EPISODE_ROW + 1 +private const val GUEST_STAR_ROW = CAST_AND_CREW_ROW + 1 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 6a2f6058..e5efbe3e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -1,22 +1,28 @@ package com.github.damontecres.wholphin.ui.detail.series +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -24,6 +30,7 @@ import androidx.compose.runtime.key 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.Modifier @@ -33,15 +40,18 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard +import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.DetailsBackdropImage import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -51,6 +61,9 @@ import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.PersonKind import kotlin.time.Duration @Composable @@ -60,10 +73,13 @@ fun SeriesOverviewContent( seasons: List, episodes: EpisodeList, chosenStreams: ChosenStreams?, + peopleInEpisode: List, position: SeriesOverviewPosition, backdropImageUrl: String?, firstItemFocusRequester: FocusRequester, episodeRowFocusRequester: FocusRequester, + castCrewRowFocusRequester: FocusRequester, + guestStarRowFocusRequester: FocusRequester, onFocus: (SeriesOverviewPosition) -> Unit, onClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit, @@ -72,8 +88,10 @@ fun SeriesOverviewContent( favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, overviewOnClick: () -> Unit, + personOnClick: (Person) -> Unit, modifier: Modifier = Modifier, ) { + val scope = rememberCoroutineScope() val bringIntoViewRequester = remember { BringIntoViewRequester() } var selectedTabIndex by rememberSaveable(position) { mutableIntStateOf(position.seasonTabIndex) } LaunchedEffect(selectedTabIndex) { @@ -87,24 +105,32 @@ fun SeriesOverviewContent( var cardRowHasFocus by remember { mutableStateOf(false) } val dimming by animateFloatAsState(if (pageHasFocus && !cardRowHasFocus) .4f else 1f) + val scrollState = rememberScrollState() + val scrollConnection = rememberDelayedNestedScroll() Box( modifier = modifier - .fillMaxWidth() -// .fillMaxHeight(.33f) - .height(460.dp) - .bringIntoViewRequester(bringIntoViewRequester), + .fillMaxWidth(), ) { DetailsBackdropImage(backdropImageUrl) - LazyColumn( + Column( verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(16.dp), modifier = Modifier - .focusRestorer(episodeRowFocusRequester) + .fillMaxSize() + .padding(16.dp) + .focusGroup() + .nestedScroll(scrollConnection) + .verticalScroll(scrollState) .onFocusChanged { pageHasFocus = it.hasFocus }, ) { - item { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .focusGroup() + .bringIntoViewRequester(bringIntoViewRequester), + ) { val paddingValues = if (preferences.appPreferences.interfacePreferences.showClock) { PaddingValues(start = 16.dp, end = 100.dp) @@ -129,21 +155,22 @@ fun SeriesOverviewContent( .padding(paddingValues) .fillMaxWidth(), ) - } - item { SeriesName(series.name, Modifier) - } - item { - // Episode header FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, chosenStreams = chosenStreams, overviewOnClick = overviewOnClick, + overviewOnFocus = { + if (it.isFocused) { + scope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }, modifier = Modifier.fillMaxWidth(.6f), ) - } - item { + key(position.seasonTabIndex) { when (val eps = episodes) { EpisodeList.Loading -> LoadingPage() @@ -216,7 +243,13 @@ fun SeriesOverviewContent( Color.Black, shape = RoundedCornerShape(8.dp), ).alpha(dimming), - ), + ).onFocusChanged { + if (it.isFocused) { + scope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }, interactionSource = interactionSource, cardHeight = 120.dp, ) @@ -225,8 +258,7 @@ fun SeriesOverviewContent( } } } - } - item { + focusedEpisode?.let { ep -> FocusedEpisodeFooter( preferences = preferences, @@ -239,6 +271,13 @@ fun SeriesOverviewContent( episodeRowFocusRequester.tryRequestFocus() }, favoriteOnClick = favoriteOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + scope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }, modifier = Modifier .fillMaxWidth() @@ -246,6 +285,53 @@ fun SeriesOverviewContent( ) } } + + val castAndCrew = + remember(peopleInEpisode) { + peopleInEpisode.filterNot { + it.type == PersonKind.GUEST_STAR + } + } + val guestStars = + remember(peopleInEpisode) { + peopleInEpisode.filter { + it.type == PersonKind.GUEST_STAR + } + } + + AnimatedVisibility( + visible = peopleInEpisode.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (castAndCrew.isNotEmpty()) { + PersonRow( + title = R.string.cast_and_crew, + people = castAndCrew, + onClick = personOnClick, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(castCrewRowFocusRequester), + ) + } + if (guestStars.isNotEmpty()) { + PersonRow( + title = R.string.guest_stars, + people = guestStars, + onClick = personOnClick, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(guestStarRowFocusRequester), + ) + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 89cd5937..4185cb0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast @@ -37,6 +38,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -80,6 +82,8 @@ class SeriesViewModel val people = MutableLiveData>(listOf()) val similar = MutableLiveData>() + val peopleInEpisode = MutableLiveData(PeopleInItem()) + fun init( prefs: UserPreferences, itemId: UUID, @@ -218,6 +222,7 @@ class SeriesViewModel ItemFields.CUSTOM_RATING, ItemFields.TRICKPLAY, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.PEOPLE, ), ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) @@ -237,11 +242,15 @@ class SeriesViewModel 0 } Timber.v("Loaded ${pager.size} episodes for season $seasonId, initialIndex=$initialIndex") - return EpisodeList.Success(pager, initialIndex) + return EpisodeList.Success(seasonId, pager, initialIndex) } fun loadEpisodes(seasonId: UUID) { - this@SeriesViewModel.episodes.value = EpisodeList.Loading + val currentEpisodes = (this@SeriesViewModel.episodes.value as? EpisodeList.Success) + if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { + this@SeriesViewModel.peopleInEpisode.value = PeopleInItem() + this@SeriesViewModel.episodes.value = EpisodeList.Loading + } viewModelScope.launchIO(ExceptionHandler(true)) { val episodes = try { @@ -253,6 +262,12 @@ class SeriesViewModel withContext(Dispatchers.Main) { this@SeriesViewModel.episodes.value = episodes } + if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { + (episodes as? EpisodeList.Success) + ?.let { + it.episodes.getOrNull(it.initialIndex) + }?.let { lookupPeopleInEpisode(it) } + } } } @@ -403,6 +418,24 @@ class SeriesViewModel } } } + + private var peopleInEpisodeJob: Job? = null + + suspend fun lookupPeopleInEpisode(item: BaseItem) { + peopleInEpisodeJob?.cancel() + if (peopleInEpisode.value?.itemId != item.id) { + peopleInEpisode.setValueOnMain(PeopleInItem()) + peopleInEpisodeJob = + viewModelScope.launch(ExceptionHandler()) { + delay(250) + val people = + item.data.people + ?.letNotEmpty { it.map { Person.fromDto(it, api) } } + .orEmpty() + peopleInEpisode.setValueOnMain(PeopleInItem(item.id, people)) + } + } + } } sealed interface EpisodeList { @@ -416,7 +449,13 @@ sealed interface EpisodeList { } data class Success( + val seasonId: UUID, val episodes: ApiRequestPager, val initialIndex: Int, ) : EpisodeList } + +data class PeopleInItem( + val itemId: UUID? = null, + val people: List = listOf(), +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/DelayedNestedScrollConnection.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/DelayedNestedScrollConnection.kt new file mode 100644 index 00000000..5092c8f1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/DelayedNestedScrollConnection.kt @@ -0,0 +1,55 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.annotation.FloatRange +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource + +class DelayedNestedScrollConnection( + @param:FloatRange(0.0, 1.0) + private val xDelay: Float = 0f, + @param:FloatRange(0.0, 1.0) + private val yDelay: Float = .6f, +) : NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: NestedScrollSource, + ): Offset = + if (source == NestedScrollSource.UserInput) { + Offset(x = available.x * xDelay, y = available.y * yDelay) + } else { + Offset.Zero + } +} + +/** + * Add a delay to the scroll speed via user interaction + * + * In some cases, slowing down scrolling makes it feel smoother and less jerky visually + * + * ```kotlin + * val scrollState = rememberScrollState() + * val scrollConnection = rememberDelayedNestedScroll(.5f, .5f) + * Column( + * modifier = + * Modifier + * .nestedScroll(scrollConnection) + * .verticalScroll(scrollState) + * ){ content() } + * ``` + * + * @param xDelay how much to delay left-right scrolling + * @param yDelay how much to delay up-down scrolling + * + * @see rememberDelayedNestedScroll + * @see androidx.compose.ui.input.nestedscroll.nestedScroll + */ +@Composable +fun rememberDelayedNestedScroll( + @FloatRange(0.0, 1.0) + xDelay: Float = 0f, + @FloatRange(0.0, 1.0) + yDelay: Float = .33f, +) = remember(xDelay, yDelay) { DelayedNestedScrollConnection(xDelay, yDelay) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 580d7144..c86d0d24 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -347,6 +347,8 @@ Aspect Ratio Show details Image type + + Guest stars