Small optimizations to home page & nav drawer (#743)

## Description
This PR has several micro optimizations to the home page & nav drawer.
Basically it computes text off of the main thread and removes redundant
calculations.
This commit is contained in:
Ray 2026-01-23 09:13:27 -05:00 committed by GitHub
parent 0639a7a1da
commit fc0de2144d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 191 additions and 243 deletions

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
@ -88,6 +89,15 @@ data class BaseItem(
@Transient @Transient
val ui = val ui =
BaseItemUi( BaseItemUi(
episodeCornerText =
data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime),
episdodeUnplayedCornerText =
data.indexNumber?.let { "E$it" }
?: data.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) },
quickDetails = quickDetails =
buildAnnotatedString { buildAnnotatedString {
val details = val details =
@ -191,5 +201,7 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
@Immutable @Immutable
data class BaseItemUi( data class BaseItemUi(
val episodeCornerText: String?,
val episdodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString, val quickDetails: AnnotatedString,
) )

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults import androidx.tv.material3.CardDefaults
@ -40,7 +41,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ImageType
/** /**
@ -63,17 +63,19 @@ fun BannerCard(
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
val fillHeight =
remember(cardHeight) {
if (cardHeight.isSpecified) {
with(density) {
cardHeight.roundToPx()
}
} else {
null
}
}
val imageUrl = val imageUrl =
remember(item, cardHeight) { remember(item, fillHeight) {
if (item != null) { if (item != null) {
val fillHeight =
if (cardHeight != Dp.Unspecified) {
with(density) {
cardHeight.roundToPx()
}
} else {
null
}
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
item, item,
ImageType.PRIMARY, ImageType.PRIMARY,
@ -101,7 +103,7 @@ fun BannerCard(
.fillMaxSize(), .fillMaxSize(),
// .background(MaterialTheme.colorScheme.surfaceVariant), // .background(MaterialTheme.colorScheme.surfaceVariant),
) { ) {
if (!imageError && imageUrl.isNotNullOrBlank()) { if (!imageError && imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
@ -121,7 +123,7 @@ fun BannerCard(
.align(Alignment.Center), .align(Alignment.Center),
) )
} }
if (played || cornerText.isNotNullOrBlank()) { if (played || cornerText != null) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -133,7 +135,7 @@ fun BannerCard(
if (played && (playPercent <= 0 || playPercent >= 100)) { if (played && (playPercent <= 0 || playPercent >= 100)) {
WatchedIcon(Modifier.size(24.dp)) WatchedIcon(Modifier.size(24.dp))
} }
if (cornerText.isNotNullOrBlank()) { if (cornerText != null) {
Box( Box(
modifier = modifier =
Modifier Modifier

View file

@ -55,7 +55,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.SeriesName
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -215,9 +214,6 @@ fun SeriesOverviewContent(
if (interactionSource.collectIsFocusedAsState().value) { if (interactionSource.collectIsFocusedAsState().value) {
onFocusEpisode.invoke(episodeIndex) onFocusEpisode.invoke(episodeIndex)
} }
val cornerText =
episode?.data?.indexNumber?.let { "E$it" }
?: episode?.data?.premiereDate?.let(::formatDateTime)
BannerCard( BannerCard(
name = episode?.name, name = episode?.name,
item = episode, item = episode,
@ -226,7 +222,7 @@ fun SeriesOverviewContent(
?.aspectRatio ?.aspectRatio
?.coerceAtLeast(AspectRatios.FOUR_THREE) ?.coerceAtLeast(AspectRatios.FOUR_THREE)
?: (AspectRatios.WIDE), ?: (AspectRatios.WIDE),
cornerText = cornerText, cornerText = episode?.ui?.episodeCornerText,
played = episode?.data?.userData?.played ?: false, played = episode?.data?.userData?.played ?: false,
playPercent = playPercent =
episode?.data?.userData?.playedPercentage episode?.data?.userData?.playedPercentage

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.main package com.github.damontecres.wholphin.ui.main
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf 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.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -49,7 +47,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
@ -88,28 +85,15 @@ fun HomePage(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
var firstLoad by rememberSaveable { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.init(preferences).join() viewModel.init()
firstLoad = false
} }
val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val loading by viewModel.loadingState.observeAsState(LoadingState.Loading)
val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading)
val watchingRows by viewModel.watchingRows.observeAsState(listOf()) val watchingRows by viewModel.watchingRows.observeAsState(listOf())
val latestRows by viewModel.latestRows.observeAsState(listOf()) val latestRows by viewModel.latestRows.observeAsState(listOf())
LaunchedEffect(loading) {
val state = loading val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows }
if (!firstLoad && state is LoadingState.Error) {
// After the first load, refreshes occur in the background and an ErrorMessage won't show
// So send a Toast on errors instead
Toast
.makeText(
context,
"Home refresh error: ${state.localizedMessage}",
Toast.LENGTH_LONG,
).show()
}
}
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -127,7 +111,7 @@ fun HomePage(
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
HomePageContent( HomePageContent(
watchingRows + latestRows, homeRows = homeRows,
onClickItem = { position, item -> onClickItem = { position, item ->
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
@ -339,21 +323,11 @@ fun HomePageContent(
.focusRequester(rowFocusRequesters[rowIndex]) .focusRequester(rowFocusRequesters[rowIndex])
.animateItem(), .animateItem(),
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
val cornerText =
remember(item) {
item?.data?.indexNumber?.let { "E$it" }
?: item
?.data
?.userData
?.unplayedItemCount
?.takeIf { it > 0 }
?.let { abbreviateNumber(it) }
}
BannerCard( BannerCard(
name = item?.data?.seriesName ?: item?.name, name = item?.data?.seriesName ?: item?.name,
item = item, item = item,
aspectRatio = AspectRatios.TALL, aspectRatio = AspectRatios.TALL,
cornerText = cornerText, cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false, played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false, favorite = item?.favorite ?: false,
playPercent = playPercent =

View file

@ -14,9 +14,11 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
@ -24,7 +26,6 @@ import com.github.damontecres.wholphin.util.LoadingState
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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -47,6 +48,7 @@ class HomeViewModel
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val latestNextUpService: LatestNextUpService, private val latestNextUpService: LatestNextUpService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService,
) : ViewModel() { ) : ViewModel() {
val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending) val loadingState = MutableLiveData<LoadingState>(LoadingState.Pending)
val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending) val refreshState = MutableLiveData<LoadingState>(LoadingState.Pending)
@ -57,18 +59,11 @@ class HomeViewModel
init { init {
datePlayedService.invalidateAll() datePlayedService.invalidateAll()
init()
} }
fun init(preferences: UserPreferences): Job { fun init() {
val reload = loadingState.value != LoadingState.Success viewModelScope.launch(
if (reload) {
loadingState.value = LoadingState.Loading
}
refreshState.value = LoadingState.Loading
this.preferences = preferences
val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow
return viewModelScope.launch(
Dispatchers.IO + Dispatchers.IO +
LoadingExceptionHandler( LoadingExceptionHandler(
loadingState, loadingState,
@ -76,67 +71,83 @@ class HomeViewModel
), ),
) { ) {
Timber.d("init HomeViewModel") Timber.d("init HomeViewModel")
val reload = loadingState.value != LoadingState.Success
if (reload) {
loadingState.setValueOnMain(LoadingState.Loading)
}
refreshState.setValueOnMain(LoadingState.Loading)
this@HomeViewModel.preferences = userPreferencesService.getCurrent()
val prefs = preferences.appPreferences.homePagePreferences
val limit = prefs.maxItemsPerRow
if (reload) { if (reload) {
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
try {
serverRepository.currentUserDto.value?.let { userDto -> serverRepository.currentUserDto.value?.let { userDto ->
val includedIds = val includedIds =
navDrawerItemRepository navDrawerItemRepository
.getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems())
.filter { it is ServerNavDrawerItem } .filter { it is ServerNavDrawerItem }
.map { (it as ServerNavDrawerItem).itemId } .map { (it as ServerNavDrawerItem).itemId }
val resume = latestNextUpService.getResume(userDto.id, limit, true) val resume = latestNextUpService.getResume(userDto.id, limit, true)
val nextUp = val nextUp =
latestNextUpService.getNextUp( latestNextUpService.getNextUp(
userDto.id, userDto.id,
limit, limit,
prefs.enableRewatchingNextUp, prefs.enableRewatchingNextUp,
false, false,
) )
val watching = val watching =
buildList { buildList {
if (prefs.combineContinueNext) { if (prefs.combineContinueNext) {
val items = latestNextUpService.buildCombined(resume, nextUp) val items = latestNextUpService.buildCombined(resume, nextUp)
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = items,
),
)
} else {
if (resume.isNotEmpty()) {
add( add(
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching), title = context.getString(R.string.continue_watching),
items = resume, items = items,
),
)
}
if (nextUp.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.next_up),
items = nextUp,
), ),
) )
} else {
if (resume.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.continue_watching),
items = resume,
),
)
}
if (nextUp.isNotEmpty()) {
add(
HomeRowLoadingState.Success(
title = context.getString(R.string.next_up),
items = nextUp,
),
)
}
} }
} }
}
val latest = latestNextUpService.getLatest(userDto, limit, includedIds) val latest = latestNextUpService.getLatest(userDto, limit, includedIds)
val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@HomeViewModel.watchingRows.value = watching this@HomeViewModel.watchingRows.value = watching
if (reload) { if (reload) {
this@HomeViewModel.latestRows.value = pendingLatest this@HomeViewModel.latestRows.value = pendingLatest
}
loadingState.value = LoadingState.Success
} }
loadingState.value = LoadingState.Success refreshState.setValueOnMain(LoadingState.Success)
val loadedLatest = latestNextUpService.loadLatest(latest)
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
}
} catch (ex: Exception) {
Timber.e(ex)
if (!reload) {
loadingState.setValueOnMain(LoadingState.Error(ex))
} else {
showToast(context, "Error refreshing home: ${ex.localizedMessage}")
} }
refreshState.setValueOnMain(LoadingState.Success)
val loadedLatest = latestNextUpService.loadLatest(latest)
this@HomeViewModel.latestRows.setValueOnMain(loadedLatest)
} }
} }
} }
@ -147,7 +158,7 @@ class HomeViewModel
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setWatched(itemId, played) favoriteWatchManager.setWatched(itemId, played)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init()
} }
} }
@ -157,7 +168,7 @@ class HomeViewModel
) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
favoriteWatchManager.setFavorite(itemId, favorite) favoriteWatchManager.setFavorite(itemId, favorite)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
init(preferences) init()
} }
} }

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.nav
import android.content.Context import android.content.Context
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
@ -14,7 +13,6 @@ 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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -26,13 +24,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
@ -94,7 +89,6 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -117,9 +111,9 @@ class NavDrawerViewModel
val backdropService: BackdropService, val backdropService: BackdropService,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
) : ViewModel() { ) : ViewModel() {
// private var all: List<NavDrawerItem>? = null
val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null) val moreLibraries = MutableLiveData<List<NavDrawerItem>>(null)
val libraries = MutableLiveData<List<NavDrawerItem>>(listOf()) val libraries = MutableLiveData<List<NavDrawerItem>>(listOf())
val selectedIndex = MutableLiveData(-1) val selectedIndex = MutableLiveData(-1)
val showMore = MutableLiveData(false) val showMore = MutableLiveData(false)
@ -133,7 +127,6 @@ class NavDrawerViewModel
fun init() { fun init() {
viewModelScope.launchIO { viewModelScope.launchIO {
val all = navDrawerItemRepository.getNavDrawerItems() val all = navDrawerItemRepository.getNavDrawerItems()
// this@NavDrawerViewModel.all = all
val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all)
val moreLibraries = all.toMutableList().apply { removeAll(libraries) } val moreLibraries = all.toMutableList().apply { removeAll(libraries) }
@ -187,6 +180,37 @@ class NavDrawerViewModel
} }
} }
fun onClickDrawerItem(
index: Int,
item: NavDrawerItem,
) {
if (item !is NavDrawerItem.More) setShowMore(false)
when (item) {
NavDrawerItem.Favorites -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Favorites,
)
}
NavDrawerItem.More -> {
setShowMore(!showMore.value!!)
}
NavDrawerItem.Discover -> {
setIndex(index)
navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> {
setIndex(index)
navigationManager.navigateToFromDrawer(item.destination)
}
}
}
fun setIndex(index: Int) { fun setIndex(index: Int) {
selectedIndex.value = index selectedIndex.value = index
} }
@ -250,7 +274,7 @@ fun NavDrawer(
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
hiltViewModel( hiltViewModel(
LocalView.current.findViewTreeViewModelStoreOwner()!!, LocalView.current.findViewTreeViewModelStoreOwner()!!,
key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either
), ),
) { ) {
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
@ -271,76 +295,14 @@ fun NavDrawer(
LaunchedEffect(Unit) { viewModel.init() } LaunchedEffect(Unit) { viewModel.init() }
val showMore by viewModel.showMore.observeAsState(false) val showMore by viewModel.showMore.observeAsState(false)
// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries
// A negative index is a built in page, >=0 is a library // A negative index is a built in page, >=0 is a library
val selectedIndex by viewModel.selectedIndex.observeAsState(-1) val selectedIndex by viewModel.selectedIndex.observeAsState(-1)
var focusedIndex by remember { mutableIntStateOf(Int.MIN_VALUE) }
val derivedFocusedIndex by remember { derivedStateOf { focusedIndex } }
fun setShowMore(value: Boolean) {
viewModel.setShowMore(value)
}
BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) {
setShowMore(false) viewModel.setShowMore(false)
} }
val onClick = { index: Int, item: NavDrawerItem -> val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth
when (item) {
NavDrawerItem.Favorites -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(
Destination.Favorites,
)
}
NavDrawerItem.More -> {
setShowMore(!showMore)
}
NavDrawerItem.Discover -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(
Destination.Discover,
)
}
is ServerNavDrawerItem -> {
viewModel.setIndex(index)
viewModel.navigationManager.navigateToFromDrawer(item.destination)
}
}
}
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
if (false && preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) {
LaunchedEffect(derivedFocusedIndex) {
val index = derivedFocusedIndex
delay(600)
if (index != selectedIndex) {
if (index == -1) {
viewModel.setIndex(-1)
viewModel.navigationManager.goToHome()
} else if (index in libraries.indices) {
if (moreLibraries.isEmpty() || index != libraries.lastIndex) {
libraries.getOrNull(index)?.let {
onClick.invoke(index, it)
}
}
} else {
val newIndex = libraries.size - index + 1
if (newIndex in moreLibraries.indices) {
moreLibraries.getOrNull(newIndex)?.let {
onClick.invoke(index, it)
}
}
}
}
}
}
val closedDrawerWidth = 40.dp
val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else closedDrawerWidth)
val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp)
val drawerBackground by animateColorAsState( val drawerBackground by animateColorAsState(
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
@ -382,15 +344,12 @@ fun NavDrawer(
modifier = modifier =
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.width(drawerWidth)
.drawBehind { .drawBehind {
drawRect(drawerBackground) drawRect(drawerBackground)
}, },
) { ) {
// Even though some must be clicked, focusing on it should clear other focused items // Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
val userImageUrl = remember(user) { viewModel.getUserImage(user) } val userImageUrl = remember(user) { viewModel.getUserImage(user) }
ProfileIcon( ProfileIcon(
user = user, user = user,
@ -403,7 +362,7 @@ fun NavDrawer(
SetupDestination.UserList(server), SetupDestination.UserList(server),
) )
}, },
modifier = Modifier.padding(start = drawerPadding), modifier = Modifier,
) )
LazyColumn( LazyColumn(
state = listState, state = listState,
@ -426,13 +385,10 @@ fun NavDrawer(
scrollToSelected() scrollToSelected()
} }
} }
}.fillMaxHeight() }.fillMaxHeight(),
.padding(start = drawerPadding),
) { ) {
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -2 }
IconNavItem( IconNavItem(
text = stringResource(R.string.search), text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
@ -449,13 +405,11 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == -2, selectedIndex == -2,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = -1 }
IconNavItem( IconNavItem(
text = stringResource(R.string.home), text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
@ -475,13 +429,11 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == -1, selectedIndex == -1,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
itemsIndexed(libraries) { index, it -> itemsIndexed(libraries) { index, it ->
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = index }
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
@ -489,31 +441,26 @@ fun NavDrawer(
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
onClick.invoke(index, it) viewModel.onClickDrawerItem(index, it)
if (it !is NavDrawerItem.More) setShowMore(false)
}, },
modifier = modifier =
Modifier Modifier
.ifElse( .ifElse(
selectedIndex == index, selectedIndex == index,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
if (showMore) { if (showMore) {
itemsIndexed(moreLibraries) { index, it -> itemsIndexed(moreLibraries) { index, it ->
val adjustedIndex = (index + libraries.size + 1) val adjustedIndex = (index + libraries.size + 1)
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) {
if (focused) focusedIndex = adjustedIndex
}
NavItem( NavItem(
library = it, library = it,
selected = selectedIndex == adjustedIndex, selected = selectedIndex == adjustedIndex,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = drawerState.isOpen,
onClick = { onClick.invoke(adjustedIndex, it) }, onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor = containerColor =
if (drawerState.isOpen) { if (drawerState.isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
@ -526,14 +473,12 @@ fun NavDrawer(
.ifElse( .ifElse(
selectedIndex == adjustedIndex, selectedIndex == adjustedIndex,
Modifier.focusRequester(focusRequester), Modifier.focusRequester(focusRequester),
).animateItem(), ),
) )
} }
} }
item { item {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE }
IconNavItem( IconNavItem(
text = stringResource(R.string.settings), text = stringResource(R.string.settings),
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
@ -547,7 +492,7 @@ fun NavDrawer(
), ),
) )
}, },
modifier = Modifier.animateItem(), modifier = Modifier,
) )
} }
} }
@ -587,7 +532,6 @@ fun NavigationDrawerScope.ProfileIcon(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val focused by interactionSource.collectIsFocusedAsState()
NavigationDrawerItem( NavigationDrawerItem(
modifier = modifier, modifier = modifier,
selected = false, selected = false,
@ -639,7 +583,7 @@ fun NavigationDrawerScope.IconNavItem(
icon, icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier.padding(0.dp), modifier = Modifier,
) )
}, },
supportingContent = supportingContent =
@ -675,29 +619,31 @@ fun NavigationDrawerScope.NavItem(
val context = LocalContext.current val context = LocalContext.current
val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV
val icon = val icon =
when (library) { remember(library) {
NavDrawerItem.Favorites -> { when (library) {
R.string.fa_heart NavDrawerItem.Favorites -> {
} R.string.fa_heart
}
NavDrawerItem.More -> { NavDrawerItem.More -> {
R.string.fa_ellipsis R.string.fa_ellipsis
} }
NavDrawerItem.Discover -> { NavDrawerItem.Discover -> {
R.string.fa_magnifying_glass_plus R.string.fa_magnifying_glass_plus
} }
is ServerNavDrawerItem -> { is ServerNavDrawerItem -> {
when (library.type) { when (library.type) {
CollectionType.MOVIES -> R.string.fa_film CollectionType.MOVIES -> R.string.fa_film
CollectionType.TVSHOWS -> R.string.fa_tv CollectionType.TVSHOWS -> R.string.fa_tv
CollectionType.HOMEVIDEOS -> R.string.fa_video CollectionType.HOMEVIDEOS -> R.string.fa_video
CollectionType.LIVETV -> R.drawable.gf_dvr CollectionType.LIVETV -> R.drawable.gf_dvr
CollectionType.MUSIC -> R.string.fa_music CollectionType.MUSIC -> R.string.fa_music
CollectionType.BOXSETS -> R.string.fa_open_folder CollectionType.BOXSETS -> R.string.fa_open_folder
CollectionType.PLAYLISTS -> R.string.fa_list_ul CollectionType.PLAYLISTS -> R.string.fa_list_ul
else -> R.string.fa_film else -> R.string.fa_film
}
} }
} }
} }
@ -732,14 +678,17 @@ fun NavigationDrawerScope.NavItem(
} }
} }
}, },
trailingContent = { trailingContent =
if (library is NavDrawerItem.More) { if (library is NavDrawerItem.More) {
Icon( {
imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, Icon(
contentDescription = null, imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft,
) contentDescription = null,
} )
}, }
} else {
null
},
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Text( Text(

View file

@ -8,8 +8,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.TimeFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import java.time.LocalDateTime import java.time.LocalDateTime
val LocalClock = compositionLocalOf<Clock> { Clock() } val LocalClock = compositionLocalOf<Clock> { Clock() }
@ -32,12 +34,14 @@ data class Clock(
fun ProvideLocalClock(content: @Composable () -> Unit) { fun ProvideLocalClock(content: @Composable () -> Unit) {
val clock = remember { Clock() } val clock = remember { Clock() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (isActive) { withContext(Dispatchers.IO) {
val now = LocalDateTime.now() while (isActive) {
val time = TimeFormatter.format(now) val now = LocalDateTime.now()
clock.now.value = now val time = TimeFormatter.format(now)
clock.timeString.value = time clock.now.value = now
delay(2_000) clock.timeString.value = time
delay(2_000)
}
} }
} }
CompositionLocalProvider(LocalClock provides clock, content) CompositionLocalProvider(LocalClock provides clock, content)