From f352cd31ba274ca3b077117f7370939790a0762a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Muller?= Date: Thu, 23 Nov 2023 15:24:56 +0100 Subject: [PATCH] Add search to TV demo --- .../pillarbox/demo/shared/di/PlayerModule.kt | 10 +- .../integrationLayer/ContentListViewModel.kt | 7 +- .../ui/integrationLayer/SearchViewModel.kt | 46 ++- .../data/ContentListSections.kt | 5 +- .../src/main/res/values/strings.xml | 3 + .../srgssr/pillarbox/demo/tv/MainActivity.kt | 29 +- .../pillarbox/demo/tv/ui/TVDemoNavigation.kt | 23 ++ .../pillarbox/demo/tv/ui/TVDemoTopBar.kt | 53 ++-- .../demo/tv/ui/integrationLayer/ListsHome.kt | 125 ++++++--- .../demo/tv/ui/integrationLayer/SearchView.kt | 264 ++++++++++++++++++ .../pillarbox/demo/ui/MainNavigation.kt | 2 +- .../demo/ui/integrationLayer/SearchView.kt | 26 +- .../src/main/res/values/strings.xml | 3 - 13 files changed, 481 insertions(+), 115 deletions(-) rename {pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo => pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared}/ui/integrationLayer/SearchViewModel.kt (68%) create mode 100644 pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt index 91dd26765..d9174a3a0 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/di/PlayerModule.kt @@ -11,6 +11,7 @@ import ch.srgssr.dataprovider.paging.DataProviderPaging import ch.srgssr.pillarbox.core.business.DefaultPillarbox import ch.srgssr.pillarbox.core.business.MediaCompositionMediaItemSource import ch.srgssr.pillarbox.core.business.images.DefaultImageScalingService +import ch.srgssr.pillarbox.core.business.images.ImageScalingService import ch.srgssr.pillarbox.core.business.integrationlayer.service.DefaultMediaCompositionDataSource import ch.srgssr.pillarbox.core.business.integrationlayer.service.IlHost import ch.srgssr.pillarbox.core.business.integrationlayer.service.Vector.getVector @@ -27,7 +28,7 @@ object PlayerModule { private fun provideIntegrationLayerItemSource(context: Context): MediaCompositionMediaItemSource = MediaCompositionMediaItemSource( mediaCompositionDataSource = DefaultMediaCompositionDataSource(vector = context.getVector()), - imageScalingService = DefaultImageScalingService() + imageScalingService = provideImageScalingService() ) /** @@ -53,6 +54,13 @@ object PlayerModule { return ILRepository(dataProviderPaging = DataProviderPaging(ilService), ilService = ilService) } + /** + * Provide a default implementation for the image scaling service. + */ + fun provideImageScalingService(): ImageScalingService { + return DefaultImageScalingService() + } + private fun providerIlHostFromUrl(ilHost: URL): ch.srg.dataProvider.integrationlayer.request.IlHost { return when (ilHost) { IlHost.STAGE -> ch.srg.dataProvider.integrationlayer.request.IlHost.STAGE diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/ContentListViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/ContentListViewModel.kt index 3b6b7fb52..45dcbace5 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/ContentListViewModel.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/ContentListViewModel.kt @@ -12,10 +12,10 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import ch.srg.dataProvider.integrationlayer.data.IlImage -import ch.srgssr.pillarbox.core.business.images.DefaultImageScalingService import ch.srgssr.pillarbox.core.business.images.ImageScalingService import ch.srgssr.pillarbox.core.business.images.ImageScalingService.ImageFormat import ch.srgssr.pillarbox.core.business.images.ImageScalingService.ImageWidth +import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.Content import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.ILRepository import kotlinx.coroutines.flow.Flow @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.map class ContentListViewModel( private val ilRepository: ILRepository, private val contentList: ContentList, - private val imageScalingService: ImageScalingService = DefaultImageScalingService() + private val imageScalingService: ImageScalingService ) : ViewModel() { /** @@ -118,9 +118,10 @@ class ContentListViewModel( class Factory( private var ilRepository: ILRepository, private val contentList: ContentList, + private val imageScalingService: ImageScalingService = PlayerModule.provideImageScalingService() ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { - return ContentListViewModel(ilRepository, contentList) as T + return ContentListViewModel(ilRepository, contentList, imageScalingService) as T } } } diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchViewModel.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/SearchViewModel.kt similarity index 68% rename from pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchViewModel.kt rename to pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/SearchViewModel.kt index 7a14235bf..52177c6d6 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchViewModel.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/SearchViewModel.kt @@ -2,8 +2,9 @@ * Copyright (c) SRG SSR. All rights reserved. * License information is available from the LICENSE file. */ -package ch.srgssr.pillarbox.demo.ui.integrationLayer +package ch.srgssr.pillarbox.demo.shared.ui.integrationLayer +import androidx.annotation.Px import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,7 +13,10 @@ import androidx.paging.LoadStates import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map +import ch.srg.dataProvider.integrationlayer.data.IlImage import ch.srg.dataProvider.integrationlayer.request.parameters.Bu +import ch.srgssr.pillarbox.core.business.images.ImageScalingService +import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.Content import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.ILRepository import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -28,12 +32,17 @@ import kotlinx.coroutines.flow.transformLatest import kotlin.time.Duration.Companion.milliseconds /** - * Search view model to search media for the chosen bu + * [ViewModel] used to search for content in a specific BU. * - * @param ilRepository - * @constructor Create empty Search view model + * @param ilRepository The repository used to load the data from the integration layer. + * @param imageScalingService The service to scale the image to display. + * + * @constructor Create a new [SearchViewModel]. */ -class SearchViewModel(private val ilRepository: ILRepository) : ViewModel() { +class SearchViewModel( + private val ilRepository: ILRepository, + private val imageScalingService: ImageScalingService +) : ViewModel() { private val _bu = MutableStateFlow(Bu.RTS) /** @@ -120,14 +129,39 @@ class SearchViewModel(private val ilRepository: ILRepository) : ViewModel() { return query.length >= VALID_SEARCH_QUERY_THRESHOLD } + /** + * Get the URL of the scaled image, in the specified format, to match as much as possible the container width. + * + * @param imageUrl The original image URL. + * @param containerWidth The width, in pixels, of the image container. + * @param format The desired format of the transformed image. + * + * @return xx + */ + fun getScaledImageUrl( + imageUrl: String, + @Px containerWidth: Int, + format: ImageScalingService.ImageFormat = ImageScalingService.ImageFormat.WEBP + ): String { + val size = IlImage.Size.getClosest(containerWidth) + val width = enumValueOf(size.name) + + return imageScalingService.getScaledImageUrl( + imageUrl = imageUrl, + width = width, + format = format + ) + } + internal data class Config(val bu: Bu, val query: String) @Suppress("UndocumentedPublicClass") class Factory( private var ilRepository: ILRepository, + private val imageScalingService: ImageScalingService = PlayerModule.provideImageScalingService() ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { - return SearchViewModel(ilRepository) as T + return SearchViewModel(ilRepository, imageScalingService) as T } } diff --git a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/data/ContentListSections.kt b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/data/ContentListSections.kt index b4cefe39d..b755d6bea 100644 --- a/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/data/ContentListSections.kt +++ b/pillarbox-demo-shared/src/main/java/ch/srgssr/pillarbox/demo/shared/ui/integrationLayer/data/ContentListSections.kt @@ -7,7 +7,10 @@ package ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data import ch.srg.dataProvider.integrationlayer.request.parameters.Bu import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.ContentList -private val bus = listOf(Bu.RSI, Bu.RTR, Bu.RTS, Bu.SRF, Bu.SWI) +/** + * All the supported BUs. + */ +val bus = listOf(Bu.RSI, Bu.RTR, Bu.RTS, Bu.SRF, Bu.SWI) /** * All the sections available in the "Lists" tab. diff --git a/pillarbox-demo-shared/src/main/res/values/strings.xml b/pillarbox-demo-shared/src/main/res/values/strings.xml index c80f2bb0d..065b880c5 100644 --- a/pillarbox-demo-shared/src/main/res/values/strings.xml +++ b/pillarbox-demo-shared/src/main/res/values/strings.xml @@ -8,4 +8,7 @@ Lists Search Showcases + Search for content + No results + Enter something to search diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt index dafb02457..a532469d5 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/MainActivity.kt @@ -7,6 +7,7 @@ package ch.srgssr.pillarbox.demo.tv import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -43,6 +44,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 58.dp) ) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurface @@ -59,30 +61,27 @@ class MainActivity : ComponentActivity() { ?.let { selectedDestination = it } } - TVDemoTopBar( - destinations = destinations, - selectedDestination = selectedDestination, - onDestinationSelected = { - selectedDestination = it + AnimatedVisibility(visible = selectedDestination != HomeDestination.Search) { + TVDemoTopBar( + destinations = destinations, + selectedDestination = selectedDestination, + modifier = Modifier.padding(vertical = 16.dp), + onDestinationClick = { destination -> + selectedDestination = destination - navController.navigate(it.route) - } - ) + navController.navigate(destination.route) + } + ) + } TVDemoNavigation( navController = navController, startDestination = startDestination, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = HorizontalPadding) + modifier = Modifier.fillMaxSize() ) } } } } } - - private companion object { - private val HorizontalPadding = 58.dp - } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoNavigation.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoNavigation.kt index e39177423..60409997a 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoNavigation.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoNavigation.kt @@ -4,19 +4,26 @@ */ package ch.srgssr.pillarbox.demo.tv.ui +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.contentListSections import ch.srgssr.pillarbox.demo.tv.examples.ExamplesHome import ch.srgssr.pillarbox.demo.tv.player.PlayerActivity import ch.srgssr.pillarbox.demo.tv.ui.integrationLayer.ListsHome +import ch.srgssr.pillarbox.demo.tv.ui.integrationLayer.SearchView import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme /** @@ -50,6 +57,22 @@ fun TVDemoNavigation( sections = contentListSections ) } + + composable(HomeDestination.Search.route) { + val context = LocalContext.current + val ilRepository = remember { + PlayerModule.createIlRepository(context) + } + + val searchViewModel = viewModel( + factory = SearchViewModel.Factory(ilRepository) + ) + + SearchView( + searchViewModel = searchViewModel, + modifier = Modifier.padding(top = 16.dp) + ) + } } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt index 88ca9dd82..462a0db4d 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/TVDemoTopBar.kt @@ -6,26 +6,25 @@ package ch.srgssr.pillarbox.demo.tv.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search 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.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.tv.foundation.lazy.list.TvLazyRow -import androidx.tv.foundation.lazy.list.items import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults import androidx.tv.material3.Text import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme @@ -36,7 +35,7 @@ import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme * @param destinations The list of destinations to display. * @param selectedDestination The currently selected destination. * @param modifier The [Modifier] to apply to the top bar. - * @param onDestinationSelected The action to perform the selected a destination. + * @param onDestinationClick The action to perform the selected a destination. */ @Composable @OptIn(ExperimentalComposeUiApi::class, ExperimentalTvMaterial3Api::class) @@ -44,43 +43,51 @@ fun TVDemoTopBar( destinations: List, selectedDestination: HomeDestination, modifier: Modifier = Modifier, - onDestinationSelected: (destination: HomeDestination) -> Unit + onDestinationClick: (destination: HomeDestination) -> Unit ) { - var isTabRowFocused by remember { mutableStateOf(false) } - - TvLazyRow( + Row( modifier = modifier .fillMaxWidth() - .focusRestorer() - .onFocusChanged { isTabRowFocused = it.isFocused || it.hasFocus }, - contentPadding = PaddingValues( - horizontal = 58.dp, - vertical = 16.dp - ), + .focusRestorer(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically ) { - items(destinations) { destination -> + destinations.forEach { destination -> ListItem( selected = destination == selectedDestination, - onClick = { onDestinationSelected(destination) }, + onClick = { onDestinationClick(destination) }, modifier = Modifier.width(IntrinsicSize.Max), headlineContent = { Text(text = stringResource(destination.labelResId)) } ) } + + Spacer(modifier = Modifier.weight(1f)) + + ListItem( + selected = selectedDestination == HomeDestination.Search, + onClick = { onDestinationClick(HomeDestination.Search) }, + modifier = Modifier.width(IntrinsicSize.Max), + shape = ListItemDefaults.shape(CircleShape), + headlineContent = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(HomeDestination.Search.labelResId) + ) + } + ) } } -@Preview @Composable +@Preview(showBackground = true) private fun TVDemoTopBarPreview() { PillarboxTheme { TVDemoTopBar( destinations = listOf(HomeDestination.Examples, HomeDestination.Lists), selectedDestination = HomeDestination.Examples, - onDestinationSelected = {} + onDestinationClick = {} ) } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt index a1ebdb109..abfed1772 100644 --- a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/ListsHome.kt @@ -101,6 +101,7 @@ import kotlin.time.Duration.Companion.seconds * @see ContentListSection */ @Composable +@OptIn(ExperimentalTvMaterial3Api::class) fun ListsHome( sections: List, modifier: Modifier = Modifier @@ -160,8 +161,10 @@ fun ListsHome( ) ListsSection( + modifier = Modifier.padding(horizontal = 16.dp), title = contentList.destinationTitle, items = viewModel.data.collectAsLazyPagingItems(), + focusFirstItem = true, scaleImageUrl = { imageUrl, containerWidth -> viewModel.getScaledImageUrl(imageUrl, containerWidth) }, @@ -191,6 +194,14 @@ fun ListsHome( navController.navigate(topic.destinationRoute) } } + }, + emptyScreen = { emptyScreenModifier -> + Box( + modifier = emptyScreenModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(R.string.no_content)) + } } ) } @@ -284,31 +295,49 @@ private fun ListsSection( } } +/** + * Display a list of [Content]. + * + * @param T The specific type of [Content] in items. + * @param modifier The [Modifier] to apply to the list. + * @param title An optional title to display at the top of the list. + * @param items The list of [Content] to display. + * @param focusFirstItem `true` to automatically focus the first, `false` otherwise. + * @param scaleImageUrl A callback used to get the URL of the scaled image, to match as much as possible the provided container width. + * @param onItemClick The action to perform when clicking on one of the items. + * @param emptyScreen The content to display when the list is empty. + */ @Composable @OptIn(ExperimentalTvMaterial3Api::class) -private fun ListsSection( - title: String, +fun ListsSection( modifier: Modifier = Modifier, - items: LazyPagingItems, + title: String? = null, + items: LazyPagingItems, + focusFirstItem: Boolean, scaleImageUrl: (imageUrl: String, containerWidth: Int) -> String, - onItemClick: (item: Content) -> Unit + onItemClick: (item: T) -> Unit, + emptyScreen: @Composable (modifier: Modifier) -> Unit ) { Column( - modifier = modifier.padding(horizontal = 16.dp), + modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = title, - style = MaterialTheme.typography.headlineLarge - ) + if (title != null) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge + ) + } when (val state = items.loadState.refresh) { is LoadState.Loading -> ListsSectionLoading(modifier = Modifier.fillMaxSize()) is LoadState.NotLoading -> ListsSectionContent( items = items, modifier = Modifier.fillMaxSize(), + focusFirstItem = focusFirstItem, scaleImageUrl = scaleImageUrl, - onItemClick = onItemClick + onItemClick = onItemClick, + emptyScreen = emptyScreen ) is LoadState.Error -> ListsSectionError( @@ -332,21 +361,20 @@ private fun ListsSectionLoading(modifier: Modifier = Modifier) { @Composable @OptIn(ExperimentalComposeUiApi::class, ExperimentalTvMaterial3Api::class) -private fun ListsSectionContent( - items: LazyPagingItems, +private fun ListsSectionContent( + items: LazyPagingItems, modifier: Modifier = Modifier, + focusFirstItem: Boolean, scaleImageUrl: (imageUrl: String, containerWidth: Int) -> String, - onItemClick: (item: Content) -> Unit + onItemClick: (item: T) -> Unit, + emptyScreen: @Composable (modifier: Modifier) -> Unit ) { if (items.itemCount == 0) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = stringResource(R.string.no_content)) - } + emptyScreen(modifier) } else { - var focusedIndex by remember(items) { mutableIntStateOf(0) } + var focusedIndex by remember(items, focusFirstItem) { + mutableIntStateOf(if (focusFirstItem) 0 else -1) + } val hasMedia = remember(items) { (0 until items.itemCount).mapNotNull { items.peek(it) }.any { it is Content.Media } } val columnCount = if (hasMedia) 3 else 4 @@ -401,27 +429,12 @@ private fun ListsSectionContent( } } ) { - when (item) { - is Content.Media -> MediaContent( - media = item, - imageUrl = scaleImageUrl(item.imageUrl, containerWidth), - imageTitle = item.imageTitle - ) - - is Content.Show -> ShowTopicContent( - title = item.title, - imageUrl = scaleImageUrl(item.imageUrl, containerWidth), - imageTitle = item.imageTitle - ) - - is Content.Topic -> ShowTopicContent( - title = item.title, - imageUrl = item.imageUrl?.let { - scaleImageUrl(it, containerWidth) - }, - imageTitle = item.imageTitle - ) - } + ContentCard( + item = item, + scaleImageUrl = { imageUrl -> + scaleImageUrl(imageUrl, containerWidth) + } + ) } } } @@ -429,6 +442,32 @@ private fun ListsSectionContent( } } +@Composable +private fun ContentCard( + item: Content, + scaleImageUrl: (imageUrl: String) -> String, +) { + when (item) { + is Content.Media -> MediaContent( + media = item, + imageUrl = scaleImageUrl(item.imageUrl), + imageTitle = item.imageTitle + ) + + is Content.Show -> ShowTopicContent( + title = item.title, + imageUrl = scaleImageUrl(item.imageUrl), + imageTitle = item.imageTitle + ) + + is Content.Topic -> ShowTopicContent( + title = item.title, + imageUrl = item.imageUrl?.let(scaleImageUrl), + imageTitle = item.imageTitle + ) + } +} + @Composable @OptIn(ExperimentalTvMaterial3Api::class) private fun MediaContent( @@ -629,8 +668,10 @@ private fun ListsSectionContentPreview() { PillarboxTheme { ListsSectionContent( items = flowOf(PagingData.from(data)).collectAsLazyPagingItems(), + focusFirstItem = true, scaleImageUrl = { imageUrl, _ -> imageUrl }, - onItemClick = {} + onItemClick = {}, + emptyScreen = {} ) } } diff --git a/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt new file mode 100644 index 000000000..deef299d8 --- /dev/null +++ b/pillarbox-demo-tv/src/main/java/ch/srgssr/pillarbox/demo/tv/ui/integrationLayer/SearchView.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) SRG SSR. All rights reserved. + * License information is available from the LICENSE file. + */ +package ch.srgssr.pillarbox.demo.tv.ui.integrationLayer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.FilterChip +import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import ch.srg.dataProvider.integrationlayer.request.parameters.Bu +import ch.srgssr.pillarbox.demo.shared.R +import ch.srgssr.pillarbox.demo.shared.data.DemoItem +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.bus +import ch.srgssr.pillarbox.demo.tv.player.PlayerActivity +import ch.srgssr.pillarbox.demo.tv.ui.theme.PillarboxTheme + +/** + * Display the list of search results. + * + * @param searchViewModel The [SearchViewModel] used to perform the search. + * @param modifier The [Modifier] to apply to the list. + */ +@Composable +fun SearchView( + searchViewModel: SearchViewModel, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val query by searchViewModel.query.collectAsState() + val selectedBu by searchViewModel.bu.collectAsState() + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SearchRow( + query = query, + bus = bus, + selectedBu = selectedBu, + modifier = Modifier.fillMaxWidth(), + onQueryChange = searchViewModel::setQuery, + onBuChange = searchViewModel::selectBu + ) + + ListsSection( + items = searchViewModel.result.collectAsLazyPagingItems(), + focusFirstItem = false, + scaleImageUrl = { imageUrl, containerWidth -> + searchViewModel.getScaledImageUrl(imageUrl, containerWidth) + }, + onItemClick = { item -> + val demoItem = DemoItem( + title = item.title, + uri = item.urn, + description = item.description, + imageUrl = item.imageUrl + ) + + PlayerActivity.startPlayer(context, demoItem) + }, + emptyScreen = { emptyScreenModifier -> + if (searchViewModel.hasValidSearchQuery()) { + NoResults(modifier = emptyScreenModifier.fillMaxSize()) + } else { + NoContent(emptyScreenModifier.fillMaxSize()) + } + } + ) + } +} + +@Composable +@OptIn(ExperimentalTvMaterial3Api::class) +private fun SearchRow( + query: String, + bus: List, + selectedBu: Bu, + modifier: Modifier = Modifier, + onQueryChange: (query: String) -> Unit, + onBuChange: (bu: Bu) -> Unit +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SearchInput( + query = query, + modifier = Modifier.fillMaxWidth(), + onQueryChange = onQueryChange + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + bus.forEach { bu -> + FilterChip( + selected = bu == selectedBu, + onClick = { onBuChange(bu) } + ) { + Text(text = bu.name.uppercase()) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalTvMaterial3Api::class) +private fun SearchInput( + query: String, + modifier: Modifier = Modifier, + onQueryChange: (query: String) -> Unit +) { + val focusRequest = remember { FocusRequester() } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .focusRequester(focusRequest) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ), + textStyle = MaterialTheme.typography.titleSmall + .copy(color = MaterialTheme.colorScheme.onSurface), + keyboardOptions = KeyboardOptions( + autoCorrect = false, + imeAction = ImeAction.Search + ), + singleLine = true, + cursorBrush = Brush.verticalGradient( + colors = listOf(LocalContentColor.current, LocalContentColor.current) + ), + decorationBox = { innerTextField -> + Box(modifier = Modifier.padding(16.dp)) { + innerTextField() + + if (query.isEmpty()) { + Text( + text = stringResource(R.string.search_placeholder), + modifier = Modifier.graphicsLayer { alpha = 0.6f }, + style = MaterialTheme.typography.titleSmall + ) + } + } + } + ) + + LaunchedEffect(Unit) { + focusRequest.requestFocus() + } +} + +@Composable +@OptIn(ExperimentalTvMaterial3Api::class) +private fun NoResults( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text(text = stringResource(R.string.no_results)) + } +} + +@Composable +@OptIn(ExperimentalTvMaterial3Api::class) +private fun NoContent( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(56.dp) + ) + + Text( + text = stringResource(R.string.empty_search_query), + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun SearchRowPreview() { + PillarboxTheme { + SearchRow( + query = "Query", + bus = bus, + selectedBu = Bu.RTS, + onQueryChange = {}, + onBuChange = {} + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun SearchInputPreview() { + PillarboxTheme { + SearchInput( + query = "Query", + onQueryChange = {} + ) + } +} + +@Composable +@Preview(showBackground = true) +private fun NoResultsPreview() { + PillarboxTheme { + NoResults() + } +} + +@Composable +@Preview(showBackground = true) +private fun NoContentPreview() { + PillarboxTheme { + NoContent() + } +} diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt index 84389a814..f60971533 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/MainNavigation.kt @@ -42,10 +42,10 @@ import ch.srgssr.pillarbox.demo.shared.data.DemoItem import ch.srgssr.pillarbox.demo.shared.di.PlayerModule import ch.srgssr.pillarbox.demo.shared.ui.HomeDestination import ch.srgssr.pillarbox.demo.shared.ui.NavigationRoutes +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.trackPagView import ch.srgssr.pillarbox.demo.ui.examples.ExamplesHome import ch.srgssr.pillarbox.demo.ui.integrationLayer.SearchView -import ch.srgssr.pillarbox.demo.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.ui.integrationLayer.listNavGraph import ch.srgssr.pillarbox.demo.ui.player.SimplePlayerActivity import ch.srgssr.pillarbox.demo.ui.showcases.showCasesNavGraph diff --git a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchView.kt b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchView.kt index 60f549745..15fd839b7 100644 --- a/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchView.kt +++ b/pillarbox-demo/src/main/java/ch/srgssr/pillarbox/demo/ui/integrationLayer/SearchView.kt @@ -62,11 +62,12 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import ch.srg.dataProvider.integrationlayer.request.parameters.Bu import ch.srgssr.pillarbox.demo.R +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.SearchViewModel import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.Content +import ch.srgssr.pillarbox.demo.shared.ui.integrationLayer.data.bus import ch.srgssr.pillarbox.demo.ui.theme.PillarboxTheme import ch.srgssr.pillarbox.demo.ui.theme.paddings - -private val bus = listOf(Bu.RSI, Bu.RTR, Bu.RTS, Bu.SRF, Bu.SWI) +import ch.srgssr.pillarbox.demo.shared.R as sharedR /** * Search view @@ -191,7 +192,7 @@ private fun SearchInput( active = false, onActiveChange = {}, modifier = modifier.focusRequester(focusRequester), - placeholder = { Text(text = stringResource(R.string.search_placeholder)) }, + placeholder = { Text(text = stringResource(sharedR.string.search_placeholder)) }, leadingIcon = { var showBuSelector by remember { mutableStateOf(false) } @@ -290,7 +291,7 @@ private fun NoContent(modifier: Modifier = Modifier) { ) Text( - text = stringResource(R.string.empty_search_query), + text = stringResource(sharedR.string.empty_search_query), modifier = Modifier.padding(top = MaterialTheme.paddings.small) ) } @@ -302,7 +303,7 @@ private fun NoResult(modifier: Modifier = Modifier) { modifier = modifier, contentAlignment = Alignment.Center ) { - Text(text = stringResource(R.string.no_results)) + Text(text = stringResource(sharedR.string.no_results)) } } @@ -345,21 +346,6 @@ private fun SearchInputPreview() { } } -@Composable -@Preview(showBackground = true) -private fun SearchInputWithPrefixPreview() { - PillarboxTheme { - SearchInput( - query = "Query", - bus = bus, - selectedBu = Bu.RTS, - onBuChange = {}, - onClearClick = {}, - onQueryChange = {} - ) - } -} - @Composable @Preview(showBackground = true) private fun NoContentPreview() { diff --git a/pillarbox-demo/src/main/res/values/strings.xml b/pillarbox-demo/src/main/res/values/strings.xml index b4668b825..c8b1f516d 100644 --- a/pillarbox-demo/src/main/res/values/strings.xml +++ b/pillarbox-demo/src/main/res/values/strings.xml @@ -21,7 +21,4 @@ Clear License URL Play - Enter something to search - Search for content - No results