Merge branch 'main' into develop/customize-home

This commit is contained in:
Damontecres 2026-02-08 13:37:03 -05:00
commit d5e3baf040
No known key found for this signature in database
25 changed files with 528 additions and 148 deletions

View file

@ -7,7 +7,6 @@ import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
@ -23,6 +22,7 @@ import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
@ActivityScoped @ActivityScoped
@ -72,21 +72,7 @@ class SuggestionsSchedulerService
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
) )
if (cache.isEmpty()) { val periodicWorkRequestBuilder =
Timber.i("Suggestions cache empty, scheduling immediate fetch")
workManager.enqueue(
OneTimeWorkRequestBuilder<SuggestionsWorker>()
.setConstraints(constraints)
.setInputData(inputData)
.build(),
)
}
Timber.i("Scheduling periodic SuggestionsWorker")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request =
PeriodicWorkRequestBuilder<SuggestionsWorker>( PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(), repeatInterval = 12.hours.toJavaDuration(),
).setConstraints(constraints) ).setConstraints(constraints)
@ -94,7 +80,18 @@ class SuggestionsSchedulerService
BackoffPolicy.EXPONENTIAL, BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(), 15.minutes.toJavaDuration(),
).setInputData(inputData) ).setInputData(inputData)
.build(),
if (cache.isEmpty()) {
Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay")
periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration())
} else {
Timber.i("Scheduling periodic SuggestionsWorker")
}
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,
existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE,
request = periodicWorkRequestBuilder.build(),
) )
} }
} }

View file

@ -133,6 +133,10 @@ class SuggestionsWorker
val isSeries = itemKind == BaseItemKind.SERIES val isSeries = itemKind == BaseItemKind.SERIES
val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind
val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1)
val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1)
val historyDeferred = val historyDeferred =
async(Dispatchers.IO) { async(Dispatchers.IO) {
fetchItems( fetchItems(
@ -141,11 +145,38 @@ class SuggestionsWorker
itemKind = historyItemType, itemKind = historyItemType,
sortBy = ItemSortBy.DATE_PLAYED, sortBy = ItemSortBy.DATE_PLAYED,
isPlayed = true, isPlayed = true,
limit = 10, limit = 20,
extraFields = listOf(ItemFields.GENRES), extraFields = listOf(ItemFields.GENRES),
).distinctBy { it.relevantId }.take(3) ).distinctBy { it.relevantId }.take(3)
} }
val seedItems = historyDeferred.await()
val allGenreIds =
seedItems
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
.distinct()
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId }
val contextualDeferred =
async(Dispatchers.IO) {
if (allGenreIds.isEmpty()) {
emptyList()
} else {
fetchItems(
parentId = parentId,
userId = userId,
itemKind = itemKind,
sortBy = ItemSortBy.RANDOM,
isPlayed = false,
limit = contextualLimit,
genreIds = allGenreIds,
excludeItemIds = excludeIds.toList(),
)
}
}
val randomDeferred = val randomDeferred =
async(Dispatchers.IO) { async(Dispatchers.IO) {
fetchItems( fetchItems(
@ -154,7 +185,7 @@ class SuggestionsWorker
itemKind = itemKind, itemKind = itemKind,
sortBy = ItemSortBy.RANDOM, sortBy = ItemSortBy.RANDOM,
isPlayed = false, isPlayed = false,
limit = itemsPerRow, limit = randomLimit,
) )
} }
@ -167,17 +198,15 @@ class SuggestionsWorker
sortBy = ItemSortBy.DATE_CREATED, sortBy = ItemSortBy.DATE_CREATED,
sortOrder = SortOrder.DESCENDING, sortOrder = SortOrder.DESCENDING,
isPlayed = false, isPlayed = false,
limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1), limit = freshLimit,
) )
} }
val seedItems = historyDeferred.await() val contextual = contextualDeferred.await()
val random = randomDeferred.await() val random = randomDeferred.await()
val fresh = freshDeferred.await() val fresh = freshDeferred.await()
val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } (contextual + fresh + random)
(fresh + random)
.asSequence() .asSequence()
.distinctBy { it.id } .distinctBy { it.id }
.filterNot { excludeIds.contains(it.relevantId) } .filterNot { excludeIds.contains(it.relevantId) }
@ -195,6 +224,7 @@ class SuggestionsWorker
limit: Int, limit: Int,
sortOrder: SortOrder? = null, sortOrder: SortOrder? = null,
genreIds: List<UUID>? = null, genreIds: List<UUID>? = null,
excludeItemIds: List<UUID>? = null,
extraFields: List<ItemFields> = emptyList(), extraFields: List<ItemFields> = emptyList(),
): List<BaseItemDto> { ): List<BaseItemDto> {
val request = val request =
@ -206,6 +236,7 @@ class SuggestionsWorker
genreIds = genreIds, genreIds = genreIds,
recursive = true, recursive = true,
isPlayed = isPlayed, isPlayed = isPlayed,
excludeItemIds = excludeItemIds,
sortBy = listOf(sortBy), sortBy = listOf(sortBy),
sortOrder = sortOrder?.let { listOf(it) }, sortOrder = sortOrder?.let { listOf(it) },
limit = limit, limit = limit,
@ -222,7 +253,6 @@ class SuggestionsWorker
const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker"
const val PARAM_USER_ID = "userId" const val PARAM_USER_ID = "userId"
const val PARAM_SERVER_ID = "serverId" const val PARAM_SERVER_ID = "serverId"
private const val FRESH_CONTENT_RATIO = 0.4
fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? =
when (collectionType) { when (collectionType) {

View file

@ -5,7 +5,6 @@ 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
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -58,7 +57,7 @@ fun <T> ItemRow(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp), modifier = Modifier,
) )
LazyRow( LazyRow(
state = state, state = state,

View file

@ -274,7 +274,7 @@ fun <T : CardGridItem> CardGrid(
horizontalArrangement = Arrangement.spacedBy(spacing), horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing), verticalArrangement = Arrangement.spacedBy(spacing),
state = gridState, state = gridState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(vertical = 16.dp),
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@ -394,7 +394,7 @@ fun <T : CardGridItem> CardGrid(
modifier = modifier =
Modifier Modifier
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(end = 16.dp), .padding(start = 16.dp),
// Add end padding to push away from edge // Add end padding to push away from edge
letterClicked = { letter -> letterClicked = { letter ->
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -40,9 +38,7 @@ fun CollectionFolderBoxSet(
recursive = recursive, recursive = recursive,
sortOptions = BoxSetSortOptions, sortOptions = BoxSetSortOptions,
initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@ -53,9 +51,7 @@ fun CollectionFolderGeneric(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = sortOptions, sortOptions = sortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -134,7 +134,7 @@ fun CollectionFolderLiveTv(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs.map { it.title }, tabs = tabs.map { it.title },
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -176,9 +176,7 @@ fun CollectionFolderLiveTv(
showTitle = false, showTitle = false,
recursive = false, recursive = false,
sortOptions = VideoSortOptions, sortOptions = VideoSortOptions,
modifier = modifier = Modifier,
Modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -83,7 +83,7 @@ fun CollectionFolderMovie(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -101,7 +101,6 @@ fun CollectionFolderMovie(
}, },
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
@ -129,7 +128,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -162,7 +160,6 @@ fun CollectionFolderMovie(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -180,7 +177,6 @@ fun CollectionFolderMovie(
includeItemTypes = listOf(BaseItemKind.MOVIE), includeItemTypes = listOf(BaseItemKind.MOVIE),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions
import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.ItemFilterBy
@ -74,9 +72,7 @@ fun CollectionFolderPhotoAlbum(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = sortOptions, sortOptions = sortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderPlaylist(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = PlaylistSortOptions, sortOptions = PlaylistSortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -1,13 +1,11 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
@ -35,9 +33,7 @@ fun CollectionFolderRecordings(
showTitle = showHeader, showTitle = showHeader,
recursive = recursive, recursive = recursive,
sortOptions = MovieSortOptions, sortOptions = MovieSortOptions,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },

View file

@ -87,7 +87,7 @@ fun CollectionFolderTv(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
modifier = modifier =
Modifier Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp) .padding(vertical = 16.dp)
.focusRequester(firstTabFocusRequester), .focusRequester(firstTabFocusRequester),
tabs = tabs, tabs = tabs,
onClick = { selectedTabIndex = it }, onClick = { selectedTabIndex = it },
@ -105,7 +105,6 @@ fun CollectionFolderTv(
}, },
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )
@ -129,7 +128,6 @@ fun CollectionFolderTv(
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
positionCallback = { columns, position -> positionCallback = { columns, position ->
@ -150,7 +148,6 @@ fun CollectionFolderTv(
includeItemTypes = listOf(BaseItemKind.SERIES), includeItemTypes = listOf(BaseItemKind.SERIES),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp)
.fillMaxSize() .fillMaxSize()
.focusRequester(focusRequester), .focusRequester(focusRequester),
) )

View file

@ -298,9 +298,7 @@ fun PersonPageContent(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
userScrollEnabled = !focusedOnHeader, userScrollEnabled = !focusedOnHeader,
modifier = modifier = modifier,
modifier
.padding(start = 16.dp),
) { ) {
item { item {
PersonHeader( PersonHeader(

View file

@ -373,7 +373,6 @@ fun PlaylistDetailsContent(
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = modifier =
Modifier Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(), .fillMaxWidth(),
) { ) {
PlaylistDetailsHeader( PlaylistDetailsHeader(
@ -386,7 +385,7 @@ fun PlaylistDetailsContent(
getPossibleFilterValues = getPossibleFilterValues, getPossibleFilterValues = getPossibleFilterValues,
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp, top = 80.dp) .padding(top = 80.dp)
.fillMaxWidth(.25f), .fillMaxWidth(.25f),
) )
when (loadingState) { when (loadingState) {

View file

@ -303,7 +303,7 @@ fun EpisodeDetailsContent(
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {

View file

@ -410,7 +410,7 @@ fun MovieDetailsContent(
Box(modifier = modifier) { Box(modifier = modifier) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
item { item {

View file

@ -333,7 +333,7 @@ fun SeriesDetailsContent(
Column( Column(
modifier = modifier =
Modifier Modifier
.padding(horizontal = 24.dp, vertical = 16.dp) .padding(vertical = 16.dp)
.fillMaxSize(), .fillMaxSize(),
) { ) {
LazyColumn( LazyColumn(

View file

@ -127,7 +127,7 @@ fun SeriesOverviewContent(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(vertical = 16.dp)
.focusGroup() .focusGroup()
.nestedScroll(scrollConnection) .nestedScroll(scrollConnection)
.verticalScroll(scrollState) .verticalScroll(scrollState)
@ -142,9 +142,9 @@ fun SeriesOverviewContent(
) { ) {
val paddingValues = val paddingValues =
if (preferences.appPreferences.interfacePreferences.showClock) { if (preferences.appPreferences.interfacePreferences.showClock) {
PaddingValues(start = 16.dp, end = 100.dp) PaddingValues(start = 0.dp, end = 184.dp)
} else { } else {
PaddingValues(start = 16.dp, end = 16.dp) PaddingValues(start = 0.dp, end = 16.dp)
} }
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -241,7 +241,7 @@ fun HomePageContent(
item = focusedItem, item = focusedItem,
modifier = modifier =
Modifier Modifier
.padding(top = 48.dp, bottom = 32.dp, start = 32.dp) .padding(top = 48.dp, bottom = 32.dp, start = 8.dp)
.fillMaxHeight(.33f), .fillMaxHeight(.33f),
) )
LazyColumn( LazyColumn(
@ -249,9 +249,6 @@ fun HomePageContent(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = contentPadding =
PaddingValues( PaddingValues(
start = 24.dp,
end = 16.dp,
top = 0.dp,
bottom = Cards.height2x3, bottom = Cards.height2x3,
), ),
modifier = modifier =

View file

@ -11,13 +11,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.runtime.saveable.rememberSerializable
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.alpha
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -32,7 +34,9 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.serialization.NavBackStackSerializer import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.runtime.serialization.NavKeySerializer
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.rememberDrawerState
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transitionFactory import coil3.request.transitionFactory
@ -91,6 +95,7 @@ fun ApplicationContent(
navigationManager.backStack = backStack navigationManager.backStack = backStack
val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle()
val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle
val drawerState = rememberDrawerState(DrawerValue.Closed)
Box( Box(
modifier = modifier, modifier = modifier,
) { ) {
@ -181,9 +186,15 @@ fun ApplicationContent(
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.fillMaxHeight(.7f) .fillMaxHeight(.7f)
.fillMaxWidth(.7f) .fillMaxWidth(.7f)
.alpha(.95f) .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent { .drawWithContent {
drawContent() drawContent()
if (drawerState.isOpen) {
drawRect(
brush = SolidColor(Color.Black),
alpha = .75f,
)
}
// Subtle top scrim for system UI readability (clock, tabs) // Subtle top scrim for system UI readability (clock, tabs)
if (enableTopScrim) { if (enableTopScrim) {
drawRect( drawRect(
@ -245,6 +256,7 @@ fun ApplicationContent(
preferences = preferences, preferences = preferences,
user = user, user = user,
server = server, server = server,
drawerState = drawerState,
onClearBackdrop = viewModel::clearBackdrop, onClearBackdrop = viewModel::clearBackdrop,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -2,7 +2,9 @@ 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.core.VisibilityThreshold
import androidx.compose.animation.core.animateIntOffsetAsState
import androidx.compose.animation.core.spring
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
@ -12,7 +14,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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
@ -24,13 +28,13 @@ 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.ReadOnlyComposable
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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
@ -44,6 +48,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@ -56,13 +61,10 @@ import androidx.tv.material3.DrawerValue
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import androidx.tv.material3.rememberDrawerState
import androidx.tv.material3.surfaceColorAtElevation import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
@ -269,6 +271,7 @@ fun NavDrawer(
preferences: UserPreferences, preferences: UserPreferences,
user: JellyfinUser, user: JellyfinUser,
server: JellyfinServer, server: JellyfinServer,
drawerState: DrawerState,
onClearBackdrop: () -> Unit, onClearBackdrop: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: NavDrawerViewModel = viewModel: NavDrawerViewModel =
@ -277,13 +280,11 @@ fun NavDrawer(
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 listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val searchFocusRequester = remember { FocusRequester() }
// If the user presses back while on the home page, open the nav drawer, another back press will quit the app // If the user presses back while on the home page, open the nav drawer, another back press will quit the app
BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) {
@ -302,26 +303,43 @@ fun NavDrawer(
viewModel.setShowMore(false) viewModel.setShowMore(false)
} }
val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth val closedDrawerWidth = CollapsedDrawerItemWidth
val drawerBackground by animateColorAsState( val openDrawerWidth = ExpandedDrawerItemWidth
if (drawerState.isOpen) { val offset by animateIntOffsetAsState(
MaterialTheme.colorScheme.surface targetValue =
} else { IntOffset(
Color.Transparent x =
with(density) {
if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0
}, },
y = 0,
),
animationSpec =
spring(
stiffness = DrawerAnimationStiffness,
visibilityThreshold = IntOffset.VisibilityThreshold,
),
) )
val spacedBy = 4.dp
val config = LocalConfiguration.current val config = LocalConfiguration.current
val density = LocalDensity.current
val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } }
ModalNavigationDrawer(
modifier = modifier,
drawerState = drawerState,
drawerContent = { drawerValue ->
val isOpen = drawerValue.isOpen
val spacedBy = 4.dp
val listState = rememberLazyListState()
val searchFocusRequester = remember { FocusRequester() }
suspend fun scrollToSelected() { suspend fun scrollToSelected() {
val target = selectedIndex + 2 val target = selectedIndex + 2
try { try {
if (target !in if (target !in
listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex listState.firstVisibleItemIndex..<listState.layoutInfo.visibleItemsInfo.lastIndex
) { ) {
val mult = if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1 val mult =
if ((target - 2) < listState.layoutInfo.totalItemsCount / 2) -1 else 1
listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2)) listState.animateScrollToItem(selectedIndex + 2, mult * (heightInPx / 2))
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@ -333,20 +351,11 @@ fun NavDrawer(
scrollToSelected() scrollToSelected()
} }
ModalNavigationDrawer(
modifier = modifier,
drawerState = drawerState,
drawerContent = {
ProvideTextStyle(MaterialTheme.typography.labelMedium) { ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacedBy), verticalArrangement = Arrangement.spacedBy(spacedBy),
modifier = modifier = Modifier.fillMaxHeight(),
Modifier
.fillMaxHeight()
.drawBehind {
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() }
@ -355,7 +364,7 @@ fun NavDrawer(
user = user, user = user,
imageUrl = userImageUrl, imageUrl = userImageUrl,
serverName = server.name ?: server.url, serverName = server.name ?: server.url,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setupNavigationManager.navigateTo( viewModel.setupNavigationManager.navigateTo(
@ -393,7 +402,7 @@ fun NavDrawer(
text = stringResource(R.string.search), text = stringResource(R.string.search),
icon = Icons.Default.Search, icon = Icons.Default.Search,
selected = selectedIndex == -2, selected = selectedIndex == -2,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-2) viewModel.setIndex(-2)
@ -414,7 +423,7 @@ fun NavDrawer(
text = stringResource(R.string.home), text = stringResource(R.string.home),
icon = Icons.Default.Home, icon = Icons.Default.Home,
selected = selectedIndex == -1, selected = selectedIndex == -1,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.setIndex(-1) viewModel.setIndex(-1)
@ -438,7 +447,7 @@ fun NavDrawer(
library = it, library = it,
selected = selectedIndex == index, selected = selectedIndex == index,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.onClickDrawerItem(index, it) viewModel.onClickDrawerItem(index, it)
@ -459,10 +468,10 @@ fun NavDrawer(
library = it, library = it,
selected = selectedIndex == adjustedIndex, selected = selectedIndex == adjustedIndex,
moreExpanded = showMore, moreExpanded = showMore,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor = containerColor =
if (drawerState.isOpen) { if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else { } else {
Color.Unspecified Color.Unspecified
@ -483,7 +492,7 @@ fun NavDrawer(
text = stringResource(R.string.settings), text = stringResource(R.string.settings),
icon = Icons.Default.Settings, icon = Icons.Default.Settings,
selected = false, selected = false,
drawerOpen = drawerState.isOpen, drawerOpen = isOpen,
interactionSource = interactionSource, interactionSource = interactionSource,
onClick = { onClick = {
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
@ -501,10 +510,7 @@ fun NavDrawer(
}, },
) { ) {
Box( Box(
modifier = modifier = Modifier.fillMaxSize(),
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
) { ) {
// Drawer content // Drawer content
DestinationContent( DestinationContent(
@ -513,7 +519,10 @@ fun NavDrawer(
onClearBackdrop = onClearBackdrop, onClearBackdrop = onClearBackdrop,
modifier = modifier =
Modifier Modifier
.fillMaxSize(), .fillMaxSize()
.offset {
offset
}.padding(start = closedDrawerWidth + 8.dp, end = 16.dp),
) )
if (preferences.appPreferences.interfacePreferences.showClock) { if (preferences.appPreferences.interfacePreferences.showClock) {
TimeDisplay() TimeDisplay()
@ -542,7 +551,7 @@ fun NavigationDrawerScope.ProfileIcon(
name = user.name, name = user.name,
imageUrl = imageUrl, imageUrl = imageUrl,
alpha = if (drawerOpen) 1f else .5f, alpha = if (drawerOpen) 1f else .5f,
modifier = Modifier.fillMaxSize(), modifier = Modifier.size(DrawerIconSize),
) )
}, },
supportingContent = { supportingContent = {
@ -583,7 +592,7 @@ fun NavigationDrawerScope.IconNavItem(
icon, icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier, modifier = Modifier.size(DrawerIconSize),
) )
}, },
supportingContent = supportingContent =
@ -673,7 +682,7 @@ fun NavigationDrawerScope.NavItem(
painter = painterResource(icon), painter = painterResource(icon),
contentDescription = null, contentDescription = null,
tint = color, tint = color,
modifier = Modifier, modifier = Modifier.size(DrawerIconSize),
) )
} }
} }
@ -700,6 +709,7 @@ fun NavigationDrawerScope.NavItem(
} }
@Composable @Composable
@ReadOnlyComposable
fun navItemColor( fun navItemColor(
selected: Boolean, selected: Boolean,
focused: Boolean, focused: Boolean,
@ -753,4 +763,6 @@ fun navItemColor(
} }
} }
val DrawerState.isOpen: Boolean get() = this.currentValue == DrawerValue.Open val DrawerState.isOpen: Boolean get() = this.currentValue.isOpen
val DrawerValue.isOpen: Boolean get() = this == DrawerValue.Open

View file

@ -0,0 +1,229 @@
/*
* This file contains code copied and modified from:
* https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-androidx-tv-material-release/tv/tv-material/src/main/java/androidx/tv/material3/
*
* Their license & required attribution is below.
*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.damontecres.wholphin.ui.nav
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.tv.material3.DrawerState
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.ListItem
import androidx.tv.material3.ListItemDefaults
import androidx.tv.material3.NavigationDrawerItemColors
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.rememberDrawerState
@Composable
fun ModalNavigationDrawer(
drawerContent: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
content: @Composable () -> Unit,
) {
Box(modifier = modifier) {
DrawerSheet(
modifier =
Modifier
.align(Alignment.CenterStart),
drawerState = drawerState,
content = drawerContent,
)
content()
}
}
@Composable
private fun DrawerSheet(
modifier: Modifier = Modifier,
drawerState: DrawerState = remember { DrawerState() },
content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit,
) {
// indicates that the drawer has been set to its initial state and has grabbed focus if
// necessary. Controls whether focus is used to decide the state of the drawer going forward.
var initializationComplete: Boolean by remember { mutableStateOf(false) }
var focusState by remember { mutableStateOf<FocusState?>(null) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(key1 = drawerState.currentValue) {
if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) {
// used to grab focus if the drawer state is set to Open on start.
focusRequester.requestFocus()
}
initializationComplete = true
}
val internalModifier =
Modifier
.focusRequester(focusRequester)
.animateContentSize(
animationSpec =
spring(
stiffness = DrawerAnimationStiffness,
visibilityThreshold = IntSize.VisibilityThreshold,
),
).fillMaxHeight()
// adding passed-in modifier here to ensure animateContentSize is called before other
// size based modifiers.
.then(modifier)
.onFocusChanged {
focusState = it
if (initializationComplete) {
drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed)
}
}.focusGroup()
Box(modifier = internalModifier) {
NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply {
content(drawerState.currentValue)
}
}
}
internal class NavigationDrawerScopeImpl(
override val hasFocus: Boolean,
) : NavigationDrawerScope
internal val CollapsedDrawerItemWidth = 64.dp
internal val ExpandedDrawerItemWidth = 224.dp
internal val DrawerIconSize = 24.dp
internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2
internal val DrawerAnimationStiffness = Spring.StiffnessMedium
@Composable
internal fun NavigationDrawerScope.NavigationDrawerItem(
selected: Boolean,
onClick: () -> Unit,
leadingContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
supportingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation,
colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(),
interactionSource: MutableInteractionSource? = null,
content: @Composable () -> Unit,
) {
val animatedWidth by
animateDpAsState(
targetValue =
if (hasFocus) {
ExpandedDrawerItemWidth
} else {
CollapsedDrawerItemWidth
},
label = "NavigationDrawerItem width open/closed state of the drawer item",
)
val navDrawerItemHeight =
if (supportingContent == null) {
NavigationDrawerItemDefaults.ContainerHeightOneLine
} else {
NavigationDrawerItemDefaults.ContainerHeightTwoLine
}
ListItem(
selected = selected,
onClick = onClick,
headlineContent = content,
leadingContent = {
Box(
Modifier
.padding(horizontal = DrawerIconPadding)
.size(DrawerIconSize),
) { leadingContent() }
},
trailingContent = trailingContent,
supportingContent = supportingContent,
modifier =
modifier
.layout { measurable, constraints ->
val width = animatedWidth.roundToPx()
val height = navDrawerItemHeight.roundToPx()
val placeable =
measurable.measure(
constraints.copy(
minWidth = width,
maxWidth = width,
minHeight = height,
maxHeight = height,
),
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
},
enabled = enabled,
onLongClick = onLongClick,
tonalElevation = tonalElevation,
colors = colors.toToggleableListItemColors(hasFocus),
scale = ListItemDefaults.scale(1f, 1f),
interactionSource = interactionSource,
)
}
@Composable
private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) =
ListItemDefaults.colors(
containerColor = containerColor,
contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor,
focusedContainerColor = focusedContainerColor,
focusedContentColor = focusedContentColor,
pressedContainerColor = pressedContainerColor,
pressedContentColor = pressedContentColor,
selectedContainerColor = selectedContainerColor,
selectedContentColor = selectedContentColor,
disabledContainerColor = disabledContainerColor,
disabledContentColor =
if (doesNavigationDrawerHaveFocus) {
disabledContentColor
} else {
disabledInactiveContentColor
},
focusedSelectedContainerColor = focusedSelectedContainerColor,
focusedSelectedContentColor = focusedSelectedContentColor,
pressedSelectedContainerColor = pressedSelectedContainerColor,
pressedSelectedContentColor = pressedSelectedContentColor,
)

View file

@ -555,16 +555,19 @@ fun Controller(
) )
} }
if (showClock) { if (showClock) {
var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) } var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(playerControls) { LaunchedEffect(playerControls) {
while (isActive) { while (isActive) {
remaining = val remaining =
(playerControls.duration - playerControls.currentPosition).milliseconds (playerControls.duration - playerControls.currentPosition)
.div(playerControls.playbackParameters.speed)
.toLong()
.milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = TimeFormatter.format(endTime)
delay(1.seconds) delay(1.seconds)
} }
} }
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
val endTimeStr = TimeFormatter.format(endTime)
Text( Text(
text = "Ends $endTimeStr", text = "Ends $endTimeStr",
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -23,10 +25,12 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class SuggestionsSchedulerServiceTest { class SuggestionsSchedulerServiceTest {
@ -87,4 +91,54 @@ class SuggestionsSchedulerServiceTest {
advanceUntilIdle() advanceUntilIdle()
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
} }
@Test
fun schedules_periodic_work_with_delay_when_cache_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns true
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
SuggestionsWorker.WORK_NAME,
any(),
capture(workRequestSlot),
)
} returns mockk()
createService()
currentLiveData.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
)
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay)
}
@Test
fun schedules_periodic_work_without_delay_when_cache_not_empty() =
runTest {
coEvery { mockCache.isEmpty() } returns false
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
SuggestionsWorker.WORK_NAME,
any(),
capture(workRequestSlot),
)
} returns mockk()
createService()
currentLiveData.value =
CurrentUser(
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
)
advanceUntilIdle()
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay)
}
} }

View file

@ -141,6 +141,85 @@ class SuggestionsWorkerTest {
} }
} }
@Test
fun fetches_contextual_suggestions_when_genres_available() =
runTest {
val viewId = UUID.randomUUID()
val view =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns viewId
every { this@mockk.collectionType } returns CollectionType.MOVIES
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
mockkObject(GetItemsRequestHandler)
val genreId = UUID.randomUUID()
val historyItem =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns UUID.randomUUID()
every { genreItems } returns listOf(mockk { every { id } returns genreId })
}
val contextualItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() }
val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() }
val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() }
var callCount = 0
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers {
callCount++
when (callCount) {
1 -> mockQueryResult(listOf(historyItem))
2 -> mockQueryResult(listOf(contextualItem))
3 -> mockQueryResult(listOf(randomItem))
4 -> mockQueryResult(listOf(freshItem))
else -> mockQueryResult(emptyList())
}
}
val result = createWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) }
}
@Test
fun skips_contextual_suggestions_when_no_genres_available() =
runTest {
val viewId = UUID.randomUUID()
val view =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns viewId
every { this@mockk.collectionType } returns CollectionType.MOVIES
}
every { mockPreferences.data } returns flowOf(mockPrefs())
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
mockkObject(GetItemsRequestHandler)
val historyItem =
mockk<BaseItemDto>(relaxed = true) {
every { id } returns UUID.randomUUID()
every { genreItems } returns emptyList()
}
val randomItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() }
val freshItem = mockk<BaseItemDto>(relaxed = true) { every { id } returns UUID.randomUUID() }
var callCount = 0
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers {
callCount++
when (callCount) {
1 -> mockQueryResult(listOf(historyItem))
2 -> mockQueryResult(listOf(randomItem))
3 -> mockQueryResult(listOf(freshItem))
else -> mockQueryResult(emptyList())
}
}
val result = createWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) }
}
@Test @Test
fun returns_retry_on_network_error() = fun returns_retry_on_network_error() =
runTest { runTest {