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.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,21 +72,7 @@ class SuggestionsSchedulerService
SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(),
)
if (cache.isEmpty()) {
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 =
val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(),
).setConstraints(constraints)
@ -94,7 +80,18 @@ class SuggestionsSchedulerService
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).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 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<UUID>? = null,
excludeItemIds: List<UUID>? = null,
extraFields: List<ItemFields> = emptyList(),
): List<BaseItemDto> {
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) {

View file

@ -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 <T> ItemRow(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(start = 8.dp),
modifier = Modifier,
)
LazyRow(
state = state,

View file

@ -274,7 +274,7 @@ fun <T : CardGridItem> 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 <T : CardGridItem> 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()) {

View file

@ -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
},

View file

@ -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
},

View file

@ -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
},

View file

@ -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),
)

View file

@ -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
},

View file

@ -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
},

View file

@ -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
},

View file

@ -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),
)

View file

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

View file

@ -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) {

View file

@ -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 {

View file

@ -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 {

View file

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

View file

@ -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,

View file

@ -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 =

View file

@ -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(),
)

View file

@ -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,26 +303,43 @@ 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() } }
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() {
val target = selectedIndex + 2
try {
if (target !in
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))
}
} catch (ex: Exception) {
@ -333,20 +351,11 @@ fun NavDrawer(
scrollToSelected()
}
ModalNavigationDrawer(
modifier = modifier,
drawerState = drawerState,
drawerContent = {
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(spacedBy),
modifier =
Modifier
.fillMaxHeight()
.drawBehind {
drawRect(drawerBackground)
},
modifier = Modifier.fillMaxHeight(),
) {
// Even though some must be clicked, focusing on it should clear other focused items
val interactionSource = remember { MutableInteractionSource() }
@ -355,7 +364,7 @@ fun NavDrawer(
user = user,
imageUrl = userImageUrl,
serverName = server.name ?: server.url,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setupNavigationManager.navigateTo(
@ -393,7 +402,7 @@ fun NavDrawer(
text = stringResource(R.string.search),
icon = Icons.Default.Search,
selected = selectedIndex == -2,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-2)
@ -414,7 +423,7 @@ fun NavDrawer(
text = stringResource(R.string.home),
icon = Icons.Default.Home,
selected = selectedIndex == -1,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.setIndex(-1)
@ -438,7 +447,7 @@ fun NavDrawer(
library = it,
selected = selectedIndex == index,
moreExpanded = showMore,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.onClickDrawerItem(index, it)
@ -459,10 +468,10 @@ fun NavDrawer(
library = it,
selected = selectedIndex == adjustedIndex,
moreExpanded = showMore,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) },
containerColor =
if (drawerState.isOpen) {
if (isOpen) {
MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)
} else {
Color.Unspecified
@ -483,7 +492,7 @@ fun NavDrawer(
text = stringResource(R.string.settings),
icon = Icons.Default.Settings,
selected = false,
drawerOpen = drawerState.isOpen,
drawerOpen = isOpen,
interactionSource = interactionSource,
onClick = {
viewModel.navigationManager.navigateTo(
@ -501,10 +510,7 @@ fun NavDrawer(
},
) {
Box(
modifier =
Modifier
.padding(start = closedDrawerWidth)
.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
) {
// Drawer content
DestinationContent(
@ -513,7 +519,10 @@ fun NavDrawer(
onClearBackdrop = onClearBackdrop,
modifier =
Modifier
.fillMaxSize(),
.fillMaxSize()
.offset {
offset
}.padding(start = closedDrawerWidth + 8.dp, end = 16.dp),
)
if (preferences.appPreferences.interfacePreferences.showClock) {
TimeDisplay()
@ -542,7 +551,7 @@ fun NavigationDrawerScope.ProfileIcon(
name = user.name,
imageUrl = imageUrl,
alpha = if (drawerOpen) 1f else .5f,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent = {
@ -583,7 +592,7 @@ fun NavigationDrawerScope.IconNavItem(
icon,
contentDescription = null,
tint = color,
modifier = Modifier,
modifier = Modifier.size(DrawerIconSize),
)
},
supportingContent =
@ -673,7 +682,7 @@ fun NavigationDrawerScope.NavItem(
painter = painterResource(icon),
contentDescription = null,
tint = color,
modifier = Modifier,
modifier = Modifier.size(DrawerIconSize),
)
}
}
@ -700,6 +709,7 @@ fun NavigationDrawerScope.NavItem(
}
@Composable
@ReadOnlyComposable
fun navItemColor(
selected: 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) {
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,

View file

@ -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<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
fun returns_retry_on_network_error() =
runTest {