diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index c967939408d..aee1437b27a 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -252,6 +252,7 @@
Recommendations
Because you liked Podcast
Keep Listening
+ Browse categories
Starred Episodes
Log in to Pocket Casts
Scan the QR code or go to %1$s
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt
new file mode 100644
index 00000000000..da7086259e8
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt
@@ -0,0 +1,93 @@
+package au.com.shiftyjelly.pocketcasts.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import au.com.shiftyjelly.pocketcasts.theme.TvTheme
+import au.com.shiftyjelly.pocketcasts.theme.tvColors
+import au.com.shiftyjelly.pocketcasts.theme.tvTypography
+import coil3.compose.AsyncImage
+
+@Composable
+fun TvCategoryTile(
+ category: DiscoverCategory,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary
+
+ TvTile(
+ onClick = onClick,
+ colors = CardDefaults.colors(
+ containerColor = MaterialTheme.tvColors.backgroundOverlay,
+ focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay,
+ ),
+ interactionSource = interactionSource,
+ modifier = modifier
+ .width(280.dp)
+ .height(128.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ AsyncImage(
+ model = category.icon,
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(contentColor),
+ modifier = Modifier.size(28.dp),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = category.name,
+ style = MaterialTheme.tvTypography.body,
+ color = contentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(horizontal = 12.dp),
+ )
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvCategoryTilePreview() {
+ TvTheme {
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.tvColors.backgroundSunken)
+ .padding(48.dp),
+ ) {
+ TvCategoryTile(
+ category = DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
+ onClick = {},
+ )
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt
index 9dca3732b59..8d564e1776c 100644
--- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt
@@ -32,6 +32,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvTopBarVisibility
import au.com.shiftyjelly.pocketcasts.nowplaying.TvNowPlayingScreen
import au.com.shiftyjelly.pocketcasts.playlists.TvPlaylistsScreen
import au.com.shiftyjelly.pocketcasts.podcasts.TvYourPodcastsScreen
+import au.com.shiftyjelly.pocketcasts.search.TvSearchScreen
import au.com.shiftyjelly.pocketcasts.theme.TvScreenBackgroundBrush
import au.com.shiftyjelly.pocketcasts.theme.TvTheme
import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight
@@ -105,8 +106,8 @@ fun TvScaffold(
onConsumeOpenRequest = { isNowPlayingOpenRequested = false },
)
- else -> Box(modifier = belowTopBar) {
- TvTabPlaceholder(tab = tab)
+ is TvTab.Search -> Box(modifier = belowTopBar) {
+ TvSearchScreen()
}
}
}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt
new file mode 100644
index 00000000000..b304e0bb7ca
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt
@@ -0,0 +1,208 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+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.FocusDirection
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.Icon
+import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import au.com.shiftyjelly.pocketcasts.component.TvTile
+import au.com.shiftyjelly.pocketcasts.theme.TvTheme
+import au.com.shiftyjelly.pocketcasts.theme.tvColors
+import au.com.shiftyjelly.pocketcasts.theme.tvTypography
+import au.com.shiftyjelly.pocketcasts.images.R as IR
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+internal fun TvSearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var editing by remember { mutableStateOf(false) }
+ var restoreRestFocus by remember { mutableStateOf(false) }
+ val fieldFocusRequester = remember { FocusRequester() }
+ val restFocusRequester = remember { FocusRequester() }
+ val focusManager = LocalFocusManager.current
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ LaunchedEffect(editing) {
+ if (editing) {
+ runCatching { fieldFocusRequester.requestFocus() }
+ keyboardController?.show()
+ } else {
+ keyboardController?.hide()
+ if (restoreRestFocus) {
+ runCatching { restFocusRequester.requestFocus() }
+ restoreRestFocus = false
+ }
+ }
+ }
+
+ if (editing) {
+ var hasFocused by remember { mutableStateOf(false) }
+
+ BackHandler(enabled = true) {
+ editing = false
+ restoreRestFocus = true
+ }
+
+ BasicTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ textStyle = MaterialTheme.tvTypography.title3.copy(color = MaterialTheme.tvColors.textPrimary),
+ cursorBrush = SolidColor(MaterialTheme.tvColors.textPrimary),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Search,
+ ),
+ keyboardActions = KeyboardActions(
+ onSearch = {
+ editing = false
+ restoreRestFocus = true
+ },
+ ),
+ modifier = modifier
+ .focusRequester(fieldFocusRequester)
+ .onFocusChanged {
+ if (it.isFocused) {
+ hasFocused = true
+ } else if (hasFocused) {
+ editing = false
+ }
+ }
+ .onPreviewKeyEvent { event ->
+ when {
+ event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown -> {
+ focusManager.moveFocus(FocusDirection.Down)
+ true
+ }
+
+ event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp -> {
+ focusManager.moveFocus(FocusDirection.Up)
+ true
+ }
+
+ else -> false
+ }
+ },
+ decorationBox = { innerTextField ->
+ TvSearchFieldContent(
+ query = query,
+ contentColor = MaterialTheme.tvColors.textPrimary,
+ innerTextField = innerTextField,
+ )
+ },
+ )
+ } else {
+ TvTile(
+ onClick = { editing = true },
+ scale = CardDefaults.scale(focusedScale = 1f),
+ shape = CardDefaults.shape(shape = RectangleShape),
+ colors = CardDefaults.colors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.tvColors.textSecondary,
+ focusedContainerColor = Color.Transparent,
+ focusedContentColor = MaterialTheme.tvColors.textPrimary,
+ pressedContainerColor = Color.Transparent,
+ ),
+ modifier = modifier.focusRequester(restFocusRequester),
+ ) {
+ TvSearchFieldContent(query = query, contentColor = LocalContentColor.current)
+ }
+ }
+}
+
+@Composable
+private fun TvSearchFieldContent(
+ query: String,
+ contentColor: Color,
+ modifier: Modifier = Modifier,
+ innerTextField: (@Composable () -> Unit)? = null,
+) {
+ Row(
+ modifier = modifier,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(IR.drawable.ic_search),
+ contentDescription = null,
+ tint = contentColor,
+ modifier = Modifier.size(32.dp),
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (query.isEmpty()) {
+ Text(
+ text = stringResource(LR.string.search),
+ style = MaterialTheme.tvTypography.title3,
+ color = MaterialTheme.tvColors.textSecondary,
+ )
+ }
+ if (innerTextField != null) {
+ innerTextField()
+ } else if (query.isNotEmpty()) {
+ Text(
+ text = query,
+ style = MaterialTheme.tvTypography.title3,
+ color = MaterialTheme.tvColors.textPrimary,
+ )
+ }
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSearchFieldPreview() {
+ TvTheme {
+ Box(
+ modifier = Modifier
+ .background(MaterialTheme.tvColors.backgroundSunken)
+ .padding(48.dp),
+ ) {
+ TvSearchField(query = "", onQueryChange = {})
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt
new file mode 100644
index 00000000000..d3c725be6f2
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt
@@ -0,0 +1,109 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.MaterialTheme
+import au.com.shiftyjelly.pocketcasts.component.TvCategoryTile
+import au.com.shiftyjelly.pocketcasts.component.TvRow
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import au.com.shiftyjelly.pocketcasts.theme.TvTheme
+import au.com.shiftyjelly.pocketcasts.theme.tvColors
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+fun TvSearchScreen(
+ modifier: Modifier = Modifier,
+ viewModel: TvSearchViewModel = hiltViewModel(),
+) {
+ var query by rememberSaveable { mutableStateOf("") }
+ val categories by viewModel.categories.collectAsStateWithLifecycle()
+
+ TvSearchContent(
+ query = query,
+ categories = categories,
+ onQueryChange = { query = it },
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun TvSearchContent(
+ query: String,
+ categories: List,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 48.dp)) {
+ Spacer(modifier = Modifier.height(40.dp))
+ TvSearchField(
+ query = query,
+ onQueryChange = onQueryChange,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+
+ if (categories.isNotEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 48.dp)
+ .height(1.dp)
+ .background(MaterialTheme.tvColors.overlayBorder),
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ TvRow(
+ title = stringResource(LR.string.tv_search_browse_categories),
+ items = categories,
+ contentPadding = PaddingValues(horizontal = 48.dp),
+ key = { it.id },
+ ) { category ->
+ TvCategoryTile(category = category, onClick = {})
+ }
+ Spacer(modifier = Modifier.height(40.dp))
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSearchScreenPreview() {
+ TvTheme {
+ Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.tvColors.backgroundSunken)) {
+ TvSearchContent(
+ query = "",
+ categories = listOf(
+ DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
+ DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""),
+ DiscoverCategory(id = 3, name = "Fiction", icon = "", source = ""),
+ ),
+ onQueryChange = {},
+ )
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt
new file mode 100644
index 00000000000..2c0dea965c9
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt
@@ -0,0 +1,41 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+@HiltViewModel
+class TvSearchViewModel @Inject constructor(
+ private val listRepository: ListRepository,
+) : ViewModel() {
+
+ private val _categories = MutableStateFlow>(emptyList())
+ val categories: StateFlow> = _categories.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ _categories.value = try {
+ listRepository.getCategoriesList(CATEGORIES_URL)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to load TV browse categories")
+ emptyList()
+ }
+ }
+ }
+
+ companion object {
+ private const val CATEGORIES_URL = "${Settings.SERVER_STATIC_URL}/discover/json/categories_v2.json"
+ }
+}
diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt
new file mode 100644
index 00000000000..3c5c9ff0856
--- /dev/null
+++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt
@@ -0,0 +1,45 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TvSearchViewModelTest {
+
+ @get:Rule
+ val coroutineRule = MainCoroutineRule()
+
+ private val listRepository = mock()
+
+ @Test
+ fun `exposes the loaded browse categories`() = runTest {
+ whenever(listRepository.getCategoriesList(any())).thenReturn(
+ listOf(category(1, "Comedy"), category(2, "True Crime")),
+ )
+
+ val viewModel = TvSearchViewModel(listRepository)
+
+ assertEquals(listOf("Comedy", "True Crime"), viewModel.categories.value.map { it.name })
+ }
+
+ @Test
+ fun `categories are empty when loading fails`() = runTest {
+ whenever(listRepository.getCategoriesList(any())).thenThrow(RuntimeException("Network error"))
+
+ val viewModel = TvSearchViewModel(listRepository)
+
+ assertTrue(viewModel.categories.value.isEmpty())
+ }
+
+ private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "")
+}