diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index 1b2132fc..42d59dae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -7,7 +7,6 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.workDataOf @@ -23,6 +22,7 @@ import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @ActivityScoped @@ -72,29 +72,26 @@ class SuggestionsSchedulerService SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), ) + val periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder( + repeatInterval = 12.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + if (cache.isEmpty()) { - Timber.i("Suggestions cache empty, scheduling immediate fetch") - workManager.enqueue( - OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .setInputData(inputData) - .build(), - ) + Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay") + periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration()) + } else { + Timber.i("Scheduling periodic SuggestionsWorker") } - Timber.i("Scheduling periodic SuggestionsWorker") workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, - request = - PeriodicWorkRequestBuilder( - repeatInterval = 12.hours.toJavaDuration(), - ).setConstraints(constraints) - .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, - 15.minutes.toJavaDuration(), - ).setInputData(inputData) - .build(), + request = periodicWorkRequestBuilder.build(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index 5222929e..ff883ff3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -133,6 +133,10 @@ class SuggestionsWorker val isSeries = itemKind == BaseItemKind.SERIES 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 = async(Dispatchers.IO) { fetchItems( @@ -141,11 +145,38 @@ class SuggestionsWorker itemKind = historyItemType, sortBy = ItemSortBy.DATE_PLAYED, isPlayed = true, - limit = 10, + limit = 20, extraFields = listOf(ItemFields.GENRES), ).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 = async(Dispatchers.IO) { fetchItems( @@ -154,7 +185,7 @@ class SuggestionsWorker itemKind = itemKind, sortBy = ItemSortBy.RANDOM, isPlayed = false, - limit = itemsPerRow, + limit = randomLimit, ) } @@ -167,17 +198,15 @@ class SuggestionsWorker sortBy = ItemSortBy.DATE_CREATED, sortOrder = SortOrder.DESCENDING, 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 fresh = freshDeferred.await() - val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } - - (fresh + random) + (contextual + fresh + random) .asSequence() .distinctBy { it.id } .filterNot { excludeIds.contains(it.relevantId) } @@ -195,6 +224,7 @@ class SuggestionsWorker limit: Int, sortOrder: SortOrder? = null, genreIds: List? = null, + excludeItemIds: List? = null, extraFields: List = emptyList(), ): List { val request = @@ -206,6 +236,7 @@ class SuggestionsWorker genreIds = genreIds, recursive = true, isPlayed = isPlayed, + excludeItemIds = excludeItemIds, sortBy = listOf(sortBy), sortOrder = sortOrder?.let { listOf(it) }, limit = limit, @@ -222,7 +253,6 @@ class SuggestionsWorker const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val PARAM_USER_ID = "userId" const val PARAM_SERVER_ID = "serverId" - private const val FRESH_CONTENT_RATIO = 0.4 fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = when (collectionType) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 314a7e1a..7ebf0faf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -58,7 +57,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier, ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index f028ed1e..8893246c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -274,7 +274,7 @@ fun CardGrid( horizontalArrangement = Arrangement.spacedBy(spacing), verticalArrangement = Arrangement.spacedBy(spacing), state = gridState, - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(vertical = 16.dp), modifier = Modifier .fillMaxSize() @@ -394,7 +394,7 @@ fun CardGrid( modifier = Modifier .align(Alignment.CenterVertically) - .padding(end = 16.dp), + .padding(start = 16.dp), // Add end padding to push away from edge letterClicked = { letter -> scope.launch(ExceptionHandler()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 91cd66f7..07c24c37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -40,9 +38,7 @@ fun CollectionFolderBoxSet( recursive = recursive, sortOptions = BoxSetSortOptions, initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 800c84c5..e0c15c26 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy @@ -53,9 +51,7 @@ fun CollectionFolderGeneric( showTitle = showHeader, recursive = recursive, sortOptions = sortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 6d1fdd02..dc426409 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -134,7 +134,7 @@ fun CollectionFolderLiveTv( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs.map { it.title }, onClick = { selectedTabIndex = it }, @@ -176,9 +176,7 @@ fun CollectionFolderLiveTv( showTitle = false, recursive = false, sortOptions = VideoSortOptions, - modifier = - Modifier - .padding(start = 16.dp), + modifier = Modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index c47aeb23..96ddbe94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -83,7 +83,7 @@ fun CollectionFolderMovie( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -101,7 +101,6 @@ fun CollectionFolderMovie( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +128,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -162,7 +160,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -180,7 +177,6 @@ fun CollectionFolderMovie( includeItemTypes = listOf(BaseItemKind.MOVIE), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index c36da909..fd4d46d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions import com.github.damontecres.wholphin.data.filter.ItemFilterBy @@ -74,9 +72,7 @@ fun CollectionFolderPhotoAlbum( showTitle = showHeader, recursive = recursive, sortOptions = sortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 66ace2d7..e7e19066 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderPlaylist( showTitle = showHeader, recursive = recursive, sortOptions = PlaylistSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 5362ddb5..a58db1b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -1,13 +1,11 @@ package com.github.damontecres.wholphin.ui.detail -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderRecordings( showTitle = showHeader, recursive = recursive, sortOptions = MovieSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index c9fd5381..d72001ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -87,7 +87,7 @@ fun CollectionFolderTv( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -105,7 +105,6 @@ fun CollectionFolderTv( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +128,6 @@ fun CollectionFolderTv( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -150,7 +148,6 @@ fun CollectionFolderTv( includeItemTypes = listOf(BaseItemKind.SERIES), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 17535577..5a01d8a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -298,9 +298,7 @@ fun PersonPageContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), userScrollEnabled = !focusedOnHeader, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, ) { item { PersonHeader( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7e552525..57c8c5d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -373,7 +373,6 @@ fun PlaylistDetailsContent( horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( @@ -386,7 +385,7 @@ fun PlaylistDetailsContent( getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp, top = 80.dp) + .padding(top = 80.dp) .fillMaxWidth(.25f), ) when (loadingState) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index c46fe0cb..ead6cfd4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -303,7 +303,7 @@ fun EpisodeDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index b4c4584d..a5d3a7c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -410,7 +410,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 1db5bb61..cef5412b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -333,7 +333,7 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(horizontal = 24.dp, vertical = 16.dp) + .padding(vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index b19e7900..86e2b398 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -127,7 +127,7 @@ fun SeriesOverviewContent( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(vertical = 16.dp) .focusGroup() .nestedScroll(scrollConnection) .verticalScroll(scrollState) @@ -142,9 +142,9 @@ fun SeriesOverviewContent( ) { val paddingValues = if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 16.dp, end = 100.dp) + PaddingValues(start = 0.dp, end = 184.dp) } else { - PaddingValues(start = 16.dp, end = 16.dp) + PaddingValues(start = 0.dp, end = 16.dp) } TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 8394af5a..cca39c83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -241,7 +241,7 @@ fun HomePageContent( item = focusedItem, modifier = Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 32.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) LazyColumn( @@ -249,9 +249,6 @@ fun HomePageContent( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues( - start = 24.dp, - end = 16.dp, - top = 0.dp, bottom = Cards.height2x3, ), modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 2ada8504..ff7643d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -11,13 +11,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush 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.platform.LocalContext 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.NavKeySerializer import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.DrawerValue import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.rememberDrawerState import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.transitionFactory @@ -91,6 +95,7 @@ fun ApplicationContent( navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { @@ -181,9 +186,15 @@ fun ApplicationContent( .align(Alignment.TopEnd) .fillMaxHeight(.7f) .fillMaxWidth(.7f) - .alpha(.95f) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { drawContent() + if (drawerState.isOpen) { + drawRect( + brush = SolidColor(Color.Black), + alpha = .75f, + ) + } // Subtle top scrim for system UI readability (clock, tabs) if (enableTopScrim) { drawRect( @@ -245,6 +256,7 @@ fun ApplicationContent( preferences = preferences, user = user, server = server, + drawerState = drawerState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f38d50bd..0b9c8135 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context 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.interaction.MutableInteractionSource 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester 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.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ModalNavigationDrawer -import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository @@ -269,6 +271,7 @@ fun NavDrawer( preferences: UserPreferences, user: JellyfinUser, server: JellyfinServer, + drawerState: DrawerState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, 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 ), ) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val listState = rememberLazyListState() val scope = rememberCoroutineScope() val context = LocalContext.current + val density = LocalDensity.current 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 BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { @@ -302,51 +303,59 @@ fun NavDrawer( viewModel.setShowMore(false) } - val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth - val drawerBackground by animateColorAsState( - if (drawerState.isOpen) { - MaterialTheme.colorScheme.surface - } else { - Color.Transparent - }, + val closedDrawerWidth = CollapsedDrawerItemWidth + val openDrawerWidth = ExpandedDrawerItemWidth + val offset by animateIntOffsetAsState( + targetValue = + IntOffset( + 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 density = LocalDensity.current val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex.. + val isOpen = drawerValue.isOpen + val spacedBy = 4.dp + val listState = rememberLazyListState() + val searchFocusRequester = remember { FocusRequester() } + + suspend fun scrollToSelected() { + val target = selectedIndex + 2 + try { + if (target !in + listState.firstVisibleItemIndex.. 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(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, + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 99313af2..ac6aaeed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -555,16 +555,19 @@ fun Controller( ) } if (showClock) { - var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) } + var endTimeStr by remember { mutableStateOf("...") } LaunchedEffect(playerControls) { while (isActive) { - remaining = - (playerControls.duration - playerControls.currentPosition).milliseconds + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) delay(1.seconds) } } - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - val endTimeStr = TimeFormatter.format(endTime) Text( text = "Ends $endTimeStr", color = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 0df0cf38..088b4678 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.MutableLiveData +import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser 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.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,10 +25,12 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import java.util.UUID +import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class SuggestionsSchedulerServiceTest { @@ -87,4 +91,54 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() 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() + 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() + 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) + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 4e388f21..ebef9aec 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -141,6 +141,85 @@ class SuggestionsWorkerTest { } } + @Test + fun fetches_contextual_suggestions_when_genres_available() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk(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(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns listOf(mockk { every { id } returns genreId }) + } + val contextualItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val randomItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk(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(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(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns emptyList() + } + val randomItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk(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 fun returns_retry_on_network_error() = runTest {