Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-11 21:14:10 -05:00
commit 561b12fe87
No known key found for this signature in database
7 changed files with 244 additions and 126 deletions

View file

@ -37,9 +37,10 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import java.util.UUID import java.util.UUID
@ -99,13 +100,13 @@ abstract class RecommendedViewModel(
abstract fun update( abstract fun update(
@StringRes title: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
) ): HomeRowLoadingState
fun update( fun update(
@StringRes title: Int, @StringRes title: Int,
block: suspend () -> List<BaseItem>, block: suspend () -> List<BaseItem>,
) { ): Deferred<HomeRowLoadingState> =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.async(Dispatchers.IO) {
val titleStr = context.getString(title) val titleStr = context.getString(title)
val row = val row =
try { try {
@ -115,7 +116,6 @@ abstract class RecommendedViewModel(
} }
update(title, row) update(title, row)
} }
}
} }
@Composable @Composable

View file

@ -32,6 +32,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -123,6 +124,8 @@ class RecommendedMovieViewModel
} }
} }
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) { update(R.string.recently_released) {
val request = val request =
GetItemsRequest( GetItemsRequest(
@ -138,7 +141,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
} }.also(jobs::add)
update(R.string.recently_added) { update(R.string.recently_added) {
val request = val request =
@ -155,7 +158,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
} }.also(jobs::add)
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val request = val request =
@ -173,7 +176,7 @@ class RecommendedMovieViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
} }.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@ -216,8 +219,17 @@ class RecommendedMovieViewModel
} }
} }
// If the continue watching row is empty, then wait until the first successful row
// is loaded before telling the UI that the page is loaded
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success) for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result.completed) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
} }
} }
} }
@ -225,10 +237,11 @@ class RecommendedMovieViewModel
override fun update( override fun update(
@StringRes title: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
) { ): HomeRowLoadingState {
rows.update { current -> rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) } current.toMutableList().apply { set(rowTitles[title]!!, row) }
} }
return row
} }
companion object { companion object {

View file

@ -33,6 +33,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -170,6 +171,8 @@ class RecommendedTvShowViewModel
} }
} }
val jobs = mutableListOf<Deferred<HomeRowLoadingState>>()
update(R.string.recently_released) { update(R.string.recently_released) {
val request = val request =
GetItemsRequest( GetItemsRequest(
@ -185,7 +188,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
} }.also(jobs::add)
update(R.string.recently_added) { update(R.string.recently_added) {
val request = val request =
@ -202,7 +205,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
} }.also(jobs::add)
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val request = val request =
@ -220,7 +223,7 @@ class RecommendedTvShowViewModel
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
} }.also(jobs::add)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
@ -264,7 +267,14 @@ class RecommendedTvShowViewModel
} }
if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {
loading.setValueOnMain(LoadingState.Success) for (i in 0..<jobs.size) {
val result = jobs[i].await()
if (result is HomeRowLoadingState.Success) {
Timber.v("First success")
loading.setValueOnMain(LoadingState.Success)
}
break
}
} }
} }
} }
@ -272,10 +282,11 @@ class RecommendedTvShowViewModel
override fun update( override fun update(
@StringRes title: Int, @StringRes title: Int,
row: HomeRowLoadingState, row: HomeRowLoadingState,
) { ): HomeRowLoadingState {
rows.update { current -> rows.update { current ->
current.toMutableList().apply { set(rowTitles[title]!!, row) } current.toMutableList().apply { set(rowTitles[title]!!, row) }
} }
return row
} }
companion object { companion object {

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import android.content.Context import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -186,6 +191,7 @@ data class DiscoverState(
val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY,
) )
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SeerrDiscoverPage( fun SeerrDiscoverPage(
preferences: UserPreferences, preferences: UserPreferences,
@ -249,28 +255,41 @@ fun SeerrDiscoverPage(
.padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .padding(top = 24.dp, bottom = 16.dp, start = 32.dp)
.fillMaxHeight(.25f), .fillMaxHeight(.25f),
) )
LazyColumn( val density = LocalDensity.current
verticalArrangement = Arrangement.spacedBy(8.dp), val spaceAbovePx =
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), with(density) {
modifier = // The size of the row titles & spacing
Modifier 50.dp.toPx()
.focusRestorer() }
.fillMaxSize(), val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
CompositionLocalProvider(
LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
) { ) {
itemsIndexed(rows) { rowIndex, row -> LazyColumn(
DiscoverRow( verticalArrangement = Arrangement.spacedBy(8.dp),
row = row, contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
onClickItem = { index, item -> modifier =
position = RowColumn(rowIndex, index) Modifier
viewModel.navigationManager.navigateTo(item.destination) .focusRestorer()
}, .fillMaxSize(),
onLongClickItem = { index, item -> }, ) {
onCardFocus = { index -> position = RowColumn(rowIndex, index) }, itemsIndexed(rows) { rowIndex, row ->
focusRequester = focusRequesters[rowIndex], CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
modifier = DiscoverRow(
Modifier row = row,
.fillMaxWidth(), onClickItem = { index, item ->
) position = RowColumn(rowIndex, index)
viewModel.navigationManager.navigateTo(item.destination)
},
onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex],
modifier =
Modifier
.fillMaxWidth(),
)
}
}
} }
} }
} }

View file

@ -1,6 +1,8 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -33,6 +36,7 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -184,6 +189,7 @@ fun HomePage(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun HomePageContent( fun HomePageContent(
homeRows: List<HomeRowLoadingState>, homeRows: List<HomeRowLoadingState>,
@ -245,101 +251,123 @@ fun HomePageContent(
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f), .fillMaxHeight(.33f),
) )
LazyColumn( val density = LocalDensity.current
state = listState, val spaceAbovePx =
verticalArrangement = Arrangement.spacedBy(8.dp), with(density) {
contentPadding = // The size of the row titles & spacing
PaddingValues( 50.dp.toPx()
bottom = Cards.height2x3, }
), val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
modifier = CompositionLocalProvider(
Modifier LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx),
.focusRestorer(),
) { ) {
itemsIndexed(homeRows) { rowIndex, row -> LazyColumn(
when (val r = row) { state = listState,
is HomeRowLoadingState.Loading, verticalArrangement = Arrangement.spacedBy(8.dp),
is HomeRowLoadingState.Pending, contentPadding =
-> { PaddingValues(
FocusableItemRow( bottom = Cards.height2x3,
title = r.title, ),
subtitle = stringResource(R.string.loading), modifier =
modifier = Modifier.animateItem(), Modifier
) .focusRestorer(),
} ) {
itemsIndexed(homeRows) { rowIndex, row ->
CompositionLocalProvider(
LocalBringIntoViewSpec provides defaultBringIntoViewSpec,
) {
when (val r = row) {
is HomeRowLoadingState.Loading,
is HomeRowLoadingState.Pending,
-> {
FocusableItemRow(
title = r.title,
subtitle = stringResource(R.string.loading),
modifier = Modifier.animateItem(),
)
}
is HomeRowLoadingState.Error -> { is HomeRowLoadingState.Error -> {
FocusableItemRow( FocusableItemRow(
title = r.title, title = r.title,
subtitle = r.localizedMessage, subtitle = r.localizedMessage,
isError = true, isError = true,
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
) )
} }
is HomeRowLoadingState.Success -> { is HomeRowLoadingState.Success -> {
if (row.items.isNotEmpty()) { if (row.items.isNotEmpty()) {
val viewOptions = row.viewOptions val viewOptions = row.viewOptions
ItemRow( ItemRow(
title = row.title, title = row.title,
items = row.items, items = row.items,
onClickItem = { index, item -> onClickItem = { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item) onClickItem.invoke(RowColumn(rowIndex, index), item)
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
onLongClickItem.invoke(RowColumn(rowIndex, index), item) onLongClickItem.invoke(
}, RowColumn(rowIndex, index),
modifier = item,
Modifier )
.fillMaxWidth() },
.focusGroup()
.focusRequester(rowFocusRequesters[rowIndex])
.animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick ->
HomePageCardContent(
index = index,
item = item,
onClick = onClick,
onLongClick = onLongClick,
viewOptions = viewOptions,
modifier = modifier =
cardModifier Modifier
.onFocusChanged { .fillMaxWidth()
if (it.isFocused) { .focusGroup()
position = RowColumn(rowIndex, index) .focusRequester(rowFocusRequesters[rowIndex])
} .animateItem(),
if (it.isFocused && onFocusPosition != null) { horizontalPadding = viewOptions.spacing.dp,
val nonEmptyRowBefore = cardContent = { index, item, cardModifier, onClick, onLongClick ->
homeRows HomePageCardContent(
.subList(0, rowIndex) index = index,
.count { item = item,
it is HomeRowLoadingState.Success && it.items.isEmpty() onClick = onClick,
} onLongClick = onLongClick,
onFocusPosition.invoke( viewOptions = viewOptions,
RowColumn( modifier =
rowIndex - nonEmptyRowBefore, cardModifier
index, .onFocusChanged {
), if (it.isFocused) {
) position =
} RowColumn(rowIndex, index)
}.onKeyEvent { }
if (isPlayKeyUp(it) && item?.type?.playable == true) { if (it.isFocused && onFocusPosition != null) {
Timber.v("Clicked play on ${item.id}") val nonEmptyRowBefore =
onClickPlay.invoke(position, item) homeRows
return@onKeyEvent true .subList(0, rowIndex)
} .count {
return@onKeyEvent false it is HomeRowLoadingState.Success && it.items.isEmpty()
}, }
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(
position,
item,
)
return@onKeyEvent true
}
return@onKeyEvent false
},
)
},
) )
}, } else if (showEmptyRows) {
) FocusableItemRow(
} else if (showEmptyRows) { title = r.title,
FocusableItemRow( subtitle = stringResource(R.string.no_results),
title = r.title, modifier = Modifier.animateItem(),
subtitle = stringResource(R.string.no_results), )
modifier = Modifier.animateItem(), }
) }
} }
} }
} }

View file

@ -0,0 +1,44 @@
package com.github.damontecres.wholphin.ui.util
import androidx.compose.foundation.gestures.BringIntoViewSpec
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.lazy.LazyColumn
/**
* Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels
*
* Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too
*
* Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides
*
* Example:
* ```kotlin
* val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
* CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) {
* LazyColumn{
* items(list){
* CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) {
* // Content
* }
* }
* }
* }
* ```
*/
class ScrollToTopBringIntoViewSpec(
val spaceAbovePx: Float = 100f,
) : BringIntoViewSpec {
override fun calculateScrollDistance(
offset: Float,
size: Float,
containerSize: Float,
): Float {
// Timber.v(
// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s",
// offset,
// size,
// containerSize,
// )
return offset - spaceAbovePx
}
}

View file

@ -49,6 +49,9 @@ sealed interface RowLoadingState {
sealed interface HomeRowLoadingState { sealed interface HomeRowLoadingState {
val title: String val title: String
val completed: Boolean
get() = this is Success || this is Error
data class Pending( data class Pending(
override val title: String, override val title: String,
) : HomeRowLoadingState ) : HomeRowLoadingState