Updates to discover tab

This commit is contained in:
Damontecres 2026-01-06 16:52:44 -05:00
parent f89eec622a
commit 4d8d389582
No known key found for this signature in database
3 changed files with 155 additions and 95 deletions

View file

@ -61,7 +61,7 @@ fun CollectionFolderMovie(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() } val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } // LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
LaunchedEffect(selectedTabIndex) { LaunchedEffect(selectedTabIndex) {
logTab("movie", selectedTabIndex) logTab("movie", selectedTabIndex)

View file

@ -59,7 +59,7 @@ fun DiscoverPage(
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") }
Column( Column(
modifier = modifier, modifier = modifier,
) { ) {
@ -83,14 +83,20 @@ fun DiscoverPage(
0 -> { 0 -> {
SeerrDiscoverPage( SeerrDiscoverPage(
preferences = preferences, preferences = preferences,
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
) )
} }
// Requests // Requests
1 -> { 1 -> {
SeerrRequestsPage( SeerrRequestsPage(
modifier = Modifier.fillMaxSize(), modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
) )
} }

View file

@ -1,30 +1,36 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import android.content.Context
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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
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
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.MediaApi
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
@ -32,14 +38,18 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.DataLoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import javax.inject.Inject import javax.inject.Inject
@ -48,35 +58,44 @@ import javax.inject.Inject
class SeerrDiscoverViewModel class SeerrDiscoverViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService, private val seerrService: SeerrService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
private val api: ApiClient, private val api: ApiClient,
private val backdropService: BackdropService, private val backdropService: BackdropService,
) : ViewModel() { ) : ViewModel() {
val recentlyAdded = MutableLiveData<List<DiscoverItem>>(listOf()) val state = MutableStateFlow<DiscoverState>(DiscoverState())
val discoverMovies = MutableLiveData<List<DiscoverItem>>(listOf())
val discoverTv = MutableLiveData<List<DiscoverItem>>(listOf())
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
backdropService.clearBackdrop() backdropService.clearBackdrop()
val tv =
seerrService.api.mediaApi
.mediaGet(
take = 20,
filter = MediaApi.FilterMediaGet.ALLAVAILABLE,
sort = MediaApi.SortMediaGet.MEDIA_ADDED,
).results
.orEmpty()
// Timber.v(tv.firstOrNull()?.jellyfinMediaId)
} }
viewModelScope.launchIO { fetchAndUpdateState(seerrService::discoverMovies) {
val movies = seerrService.discoverMovies() this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it))
discoverMovies.setValueOnMain(movies)
} }
fetchAndUpdateState(seerrService::discoverTv) {
this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it))
}
}
private fun fetchAndUpdateState(
getData: suspend () -> List<DiscoverItem>,
copyFunc: DiscoverState.(DataLoadingState<List<DiscoverItem>>) -> DiscoverState,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
val tv = seerrService.discoverTv() state.update {
discoverTv.setValueOnMain(tv) copyFunc.invoke(it, DataLoadingState.Loading)
}
try {
val results = getData.invoke()
state.update {
copyFunc.invoke(it, DataLoadingState.Success(results))
}
} catch (ex: Exception) {
state.update {
copyFunc.invoke(it, DataLoadingState.Error(ex))
}
}
} }
} }
@ -89,32 +108,46 @@ class SeerrDiscoverViewModel
} }
} }
data class DiscoverRowData(
val title: String,
val items: DataLoadingState<List<DiscoverItem>>,
) {
companion object {
val EMPTY = DiscoverRowData("", DataLoadingState.Pending)
}
}
data class DiscoverState(
val movies: DiscoverRowData = DiscoverRowData.EMPTY,
val tv: DiscoverRowData = DiscoverRowData.EMPTY,
)
@Composable @Composable
fun SeerrDiscoverPage( fun SeerrDiscoverPage(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SeerrDiscoverViewModel = hiltViewModel(), viewModel: SeerrDiscoverViewModel = hiltViewModel(),
) { ) {
val movies by viewModel.discoverMovies.observeAsState(listOf()) val state by viewModel.state.collectAsState()
val tv by viewModel.discoverTv.observeAsState(listOf()) val rows = listOf(state.movies, state.tv)
val focusRequester = remember { FocusRequester() } val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } }
LaunchedEffect(movies) { if (movies.isNotEmpty()) focusRequester.tryRequestFocus() } var position by rememberPosition(0, -1)
val scrollState = rememberScrollState()
var position by rememberPosition()
LaunchedEffect(position) {
position.let {
val item = if (it.row == 0) movies.getOrNull(it.column) else tv.getOrNull(it.column)
viewModel.updateBackdrop(item)
}
}
val focusedItem = val focusedItem =
remember(position) { remember(position) {
position.let { position.let {
if (it.row == 0) movies.getOrNull(it.column) else tv.getOrNull(it.column) (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column)
} }
} }
LaunchedEffect(focusedItem) {
viewModel.updateBackdrop(focusedItem)
}
var firstFocused by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(state.movies) {
if (!firstFocused && state.movies.items is DataLoadingState.Success<*>) {
firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true
}
}
Column( Column(
modifier = modifier, modifier = modifier,
@ -129,33 +162,85 @@ fun SeerrDiscoverPage(
}, },
modifier = modifier =
Modifier Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 32.dp) .fillMaxHeight(.33f)
.fillMaxHeight(.33f), .padding(top = 48.dp, bottom = 32.dp, start = 32.dp),
) )
Column( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp),
modifier = modifier =
Modifier Modifier
.verticalScroll(scrollState) .focusRestorer()
.fillMaxSize() .fillMaxSize(),
.padding(start = 16.dp, end = 16.dp, bottom = 40.dp),
) { ) {
itemsIndexed(rows) { rowIndex, row ->
DiscoverRow(
row = row,
onClickItem = { index, item ->
position = RowColumn(rowIndex, index)
if (item.jellyfinItemId != null) {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = BaseItemKind.MOVIE,
),
)
} else {
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
}
},
onLongClickItem = { index, item -> },
onCardFocus = { index -> position = RowColumn(rowIndex, index) },
focusRequester = focusRequesters[rowIndex],
modifier =
Modifier
.fillMaxWidth(),
)
}
}
}
}
@Composable
fun DiscoverRow(
row: DiscoverRowData,
onClickItem: (Int, DiscoverItem) -> Unit,
onLongClickItem: (Int, DiscoverItem) -> Unit,
onCardFocus: (Int) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
when (val state = row.items) {
is DataLoadingState.Error -> {
ErrorMessage(state.message, state.exception, modifier)
}
DataLoadingState.Loading,
DataLoadingState.Pending,
-> {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = row.title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringResource(R.string.loading),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
is DataLoadingState.Success<List<DiscoverItem>> -> {
ItemRow( ItemRow(
title = stringResource(R.string.movies), title = row.title,
items = movies, items = state.data,
onClickItem = { index, item -> onClickItem = onClickItem,
if (item.jellyfinItemId != null) { onLongClickItem = onLongClickItem,
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = BaseItemKind.MOVIE,
),
)
} else {
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
}
},
onLongClickItem = { index, item -> },
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard( DiscoverItemCard(
item = item, item = item,
@ -165,43 +250,12 @@ fun SeerrDiscoverPage(
modifier = modifier =
mod.onFocusChanged { mod.onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
position = RowColumn(0, index) onCardFocus.invoke(index)
}
},
)
},
modifier = Modifier.focusRequester(focusRequester),
)
ItemRow(
title = stringResource(R.string.tv_shows),
items = tv,
onClickItem = { index, item ->
if (item.jellyfinItemId != null) {
viewModel.navigationManager.navigateTo(
Destination.MediaItem(
itemId = item.jellyfinItemId,
type = BaseItemKind.SERIES,
),
)
} else {
viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item))
}
},
onLongClickItem = { index, item -> },
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
DiscoverItemCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
showOverlay = false,
modifier =
mod.onFocusChanged {
if (it.isFocused) {
position = RowColumn(1, index)
} }
}, },
) )
}, },
modifier = modifier.focusRequester(focusRequester),
) )
} }
} }