From 21ddc23fe61b3cc4222eca11bfc06bab1c7b5265 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 13:54:24 +0200 Subject: [PATCH 01/28] Add a custom on-screen keyboard component for TV search --- .../src/main/res/values/strings.xml | 3 + .../pocketcasts/search/TvSearchKeyboard.kt | 295 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index c967939408d..a8f5f0138a6 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -252,6 +252,9 @@ Recommendations Because you liked Podcast Keep Listening + Space + 123 + ABC 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/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt new file mode 100644 index 00000000000..a86492ff98b --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt @@ -0,0 +1,295 @@ +package au.com.shiftyjelly.pocketcasts.search + +import android.view.KeyEvent.KEYCODE_DEL +import android.view.KeyEvent.KEYCODE_DPAD_CENTER +import android.view.KeyEvent.KEYCODE_DPAD_LEFT +import android.view.KeyEvent.KEYCODE_DPAD_RIGHT +import android.view.KeyEvent.KEYCODE_ENTER +import android.view.KeyEvent.KEYCODE_NUMPAD_ENTER +import android.view.KeyEvent.KEYCODE_SPACE +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +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.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +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.localization.R as LR + +sealed interface TvSearchKey { + data class Character(val char: Char) : TvSearchKey + data object Space : TvSearchKey + data object Delete : TvSearchKey + data object TogglePage : TvSearchKey +} + +private enum class TvSearchKeyboardPage { Letters, Symbols } + +private val LettersKeys: List = buildList { + add(TvSearchKey.TogglePage) + add(TvSearchKey.Space) + ('a'..'z').forEach { add(TvSearchKey.Character(it)) } + add(TvSearchKey.Delete) +} + +private val SymbolsKeys: List = buildList { + add(TvSearchKey.TogglePage) + add(TvSearchKey.Space) + ('0'..'9').forEach { add(TvSearchKey.Character(it)) } + ".,'-_&@".forEach { add(TvSearchKey.Character(it)) } + add(TvSearchKey.Delete) +} + +private val InitialSelectedIndex = LettersKeys.indexOfFirst { it is TvSearchKey.Character } + +@Stable +class TvSearchKeyboardState { + private var page by mutableStateOf(TvSearchKeyboardPage.Letters) + + var selectedIndex by mutableIntStateOf(InitialSelectedIndex) + private set + + var isFocused by mutableStateOf(false) + private set + + private var consumedLastLeftRight = false + + val keys: List get() = if (page == TvSearchKeyboardPage.Letters) LettersKeys else SymbolsKeys + val isSymbolsPage: Boolean get() = page == TvSearchKeyboardPage.Symbols + val selectedKey: TvSearchKey get() = keys[selectedIndex] + + fun onFocusChanged(focused: Boolean) { + isFocused = focused + } + + fun togglePage() { + page = if (page == TvSearchKeyboardPage.Letters) TvSearchKeyboardPage.Symbols else TvSearchKeyboardPage.Letters + if (selectedIndex > keys.lastIndex) { + selectedIndex = keys.lastIndex + } + } + + fun isSelected(index: Int): Boolean = isFocused && selectedIndex == index + + fun handleDpadDirection(keyCode: Int, isKeyDown: Boolean): Boolean { + if (keyCode != KEYCODE_DPAD_LEFT && keyCode != KEYCODE_DPAD_RIGHT) return false + return if (isKeyDown) { + consumedLastLeftRight = when (keyCode) { + KEYCODE_DPAD_RIGHT -> (selectedIndex < keys.lastIndex).also { if (it) selectedIndex++ } + KEYCODE_DPAD_LEFT -> (selectedIndex > 0).also { if (it) selectedIndex-- } + else -> false + } + consumedLastLeftRight + } else { + consumedLastLeftRight.also { consumedLastLeftRight = false } + } + } +} + +@Composable +fun rememberTvSearchKeyboardState(): TvSearchKeyboardState = remember { TvSearchKeyboardState() } + +@Composable +internal fun TvSearchKeyboard( + onCharacter: (Char) -> Unit, + onSpace: () -> Unit, + onDelete: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, + state: TvSearchKeyboardState = rememberTvSearchKeyboardState(), +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + withFrameNanos {} + runCatching { focusRequester.requestFocus() } + } + + fun activate(key: TvSearchKey) { + when (key) { + is TvSearchKey.Character -> onCharacter(key.char) + TvSearchKey.Space -> onSpace() + TvSearchKey.Delete -> onDelete() + TvSearchKey.TogglePage -> state.togglePage() + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .focusRequester(focusRequester) + .onFocusChanged { state.onFocusChanged(it.isFocused) } + .onPreviewKeyEvent { event -> + val keyCode = event.key.nativeKeyCode + val isKeyDown = event.type == KeyEventType.KeyDown + when (keyCode) { + KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) + + KEYCODE_DPAD_CENTER -> { + if (isKeyDown) activate(state.selectedKey) + true + } + + KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { + if (isKeyDown) onSubmit() + true + } + + KEYCODE_DEL -> { + if (isKeyDown) onDelete() + true + } + + KEYCODE_SPACE -> { + if (isKeyDown) onSpace() + true + } + + else -> { + val unicodeChar = event.nativeKeyEvent.getUnicodeChar(event.nativeKeyEvent.metaState) + if (unicodeChar != 0 && !Character.isISOControl(unicodeChar)) { + if (isKeyDown) onCharacter(unicodeChar.toChar()) + true + } else { + false + } + } + } + } + .focusable(), + ) { + state.keys.forEachIndexed { index, key -> + TvSearchKeyCap( + key = key, + selected = state.isSelected(index), + isSymbolsPage = state.isSymbolsPage, + ) + } + } +} + +@Composable +private fun TvSearchKeyCap( + key: TvSearchKey, + selected: Boolean, + isSymbolsPage: Boolean, +) { + val hasPersistentBackground = key is TvSearchKey.Space || key is TvSearchKey.TogglePage + val background = when { + selected -> MaterialTheme.tvColors.backgroundActive + hasPersistentBackground -> MaterialTheme.tvColors.backgroundActive20 + else -> Color.Transparent + } + val contentColor = if (selected) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textSecondary + val deleteLabel = stringResource(LR.string.delete) + val shape = RoundedCornerShape(8.dp) + val sizeModifier = when (key) { + is TvSearchKey.Character -> Modifier.width(24.dp).height(48.dp).clip(shape).background(background) + + TvSearchKey.Delete -> Modifier.width(42.dp).height(48.dp).clip(shape).background(background) + + TvSearchKey.Space, TvSearchKey.TogglePage -> + Modifier.clip(shape).background(background).padding(horizontal = 12.dp, vertical = 6.dp) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .scale(if (selected) 1.25f else 1f) + .then(sizeModifier), + ) { + when (key) { + is TvSearchKey.Character -> Text( + text = key.char.toString(), + style = MaterialTheme.tvTypography.subtitle1, + color = contentColor, + ) + + TvSearchKey.Space -> Text( + text = stringResource(LR.string.tv_search_key_space), + style = MaterialTheme.tvTypography.caption1, + color = contentColor, + ) + + TvSearchKey.Delete -> Text( + text = "⌫", + style = MaterialTheme.tvTypography.title3, + color = contentColor, + modifier = Modifier.semantics { contentDescription = deleteLabel }, + ) + + TvSearchKey.TogglePage -> Text( + text = stringResource( + if (isSymbolsPage) LR.string.tv_search_key_letters else LR.string.tv_search_key_symbols, + ), + style = MaterialTheme.tvTypography.caption1, + color = contentColor, + ) + } + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSearchKeyboardPreview() { + TvTheme { + var query by remember { mutableStateOf("") } + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.tvColors.backgroundSunken) + .padding(48.dp), + ) { + Text( + text = query.ifEmpty { "Type with the remote or a keyboard…" }, + style = MaterialTheme.tvTypography.title2, + color = MaterialTheme.tvColors.textPrimary, + ) + Spacer(modifier = Modifier.height(24.dp)) + TvSearchKeyboard( + onCharacter = { query += it }, + onSpace = { query += ' ' }, + onDelete = { query = query.dropLast(1) }, + onSubmit = {}, + ) + } + } +} From ab117d3374eb01f072247239c9c8d1f722c21b38 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 14:13:50 +0200 Subject: [PATCH 02/28] Address PR comments --- .../src/main/res/values/strings.xml | 1 + .../pocketcasts/search/TvSearchKeyboard.kt | 142 ++++++++++++------ .../search/TvSearchKeyboardStateTest.kt | 82 ++++++++++ 3 files changed, 175 insertions(+), 50 deletions(-) create mode 100644 tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index a8f5f0138a6..6cd64538726 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 + On-screen keyboard Space 123 ABC diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt index a86492ff98b..6ea33dcfcf4 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt @@ -1,5 +1,7 @@ package au.com.shiftyjelly.pocketcasts.search +import android.view.InputDevice +import android.view.KeyCharacterMap import android.view.KeyEvent.KEYCODE_DEL import android.view.KeyEvent.KEYCODE_DPAD_CENTER import android.view.KeyEvent.KEYCODE_DPAD_LEFT @@ -20,6 +22,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -41,11 +44,16 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -54,7 +62,7 @@ import au.com.shiftyjelly.pocketcasts.theme.tvColors import au.com.shiftyjelly.pocketcasts.theme.tvTypography import au.com.shiftyjelly.pocketcasts.localization.R as LR -sealed interface TvSearchKey { +internal sealed interface TvSearchKey { data class Character(val char: Char) : TvSearchKey data object Space : TvSearchKey data object Delete : TvSearchKey @@ -81,7 +89,7 @@ private val SymbolsKeys: List = buildList { private val InitialSelectedIndex = LettersKeys.indexOfFirst { it is TvSearchKey.Character } @Stable -class TvSearchKeyboardState { +internal class TvSearchKeyboardState { private var page by mutableStateOf(TvSearchKeyboardPage.Letters) var selectedIndex by mutableIntStateOf(InitialSelectedIndex) @@ -125,7 +133,7 @@ class TvSearchKeyboardState { } @Composable -fun rememberTvSearchKeyboardState(): TvSearchKeyboardState = remember { TvSearchKeyboardState() } +internal fun rememberTvSearchKeyboardState(): TvSearchKeyboardState = remember { TvSearchKeyboardState() } @Composable internal fun TvSearchKeyboard( @@ -134,12 +142,15 @@ internal fun TvSearchKeyboard( onDelete: () -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, + autoFocus: Boolean = true, state: TvSearchKeyboardState = rememberTvSearchKeyboardState(), ) { val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - withFrameNanos {} - runCatching { focusRequester.requestFocus() } + if (autoFocus) { + LaunchedEffect(Unit) { + withFrameNanos {} + runCatching { focusRequester.requestFocus() } + } } fun activate(key: TvSearchKey) { @@ -151,57 +162,90 @@ internal fun TvSearchKeyboard( } } - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .focusRequester(focusRequester) - .onFocusChanged { state.onFocusChanged(it.isFocused) } - .onPreviewKeyEvent { event -> - val keyCode = event.key.nativeKeyCode - val isKeyDown = event.type == KeyEventType.KeyDown - when (keyCode) { - KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) - - KEYCODE_DPAD_CENTER -> { - if (isKeyDown) activate(state.selectedKey) - true - } + val keyboardDescription = stringResource(LR.string.tv_search_keyboard) + val selectedKeyLabel = when (val key = state.selectedKey) { + is TvSearchKey.Character -> key.char.toString() - KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { - if (isKeyDown) onSubmit() - true - } + TvSearchKey.Space -> stringResource(LR.string.tv_search_key_space) - KEYCODE_DEL -> { - if (isKeyDown) onDelete() - true - } + TvSearchKey.Delete -> stringResource(LR.string.delete) - KEYCODE_SPACE -> { - if (isKeyDown) onSpace() - true - } + TvSearchKey.TogglePage -> stringResource( + if (state.isSymbolsPage) LR.string.tv_search_key_letters else LR.string.tv_search_key_symbols, + ) + } - else -> { - val unicodeChar = event.nativeKeyEvent.getUnicodeChar(event.nativeKeyEvent.metaState) - if (unicodeChar != 0 && !Character.isISOControl(unicodeChar)) { - if (isKeyDown) onCharacter(unicodeChar.toChar()) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .focusRequester(focusRequester) + .onFocusChanged { state.onFocusChanged(it.isFocused) } + .semantics { + contentDescription = keyboardDescription + stateDescription = selectedKeyLabel + liveRegion = LiveRegionMode.Polite + } + .onPreviewKeyEvent { event -> + val keyCode = event.key.nativeKeyCode + val isKeyDown = event.type == KeyEventType.KeyDown + when (keyCode) { + KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) + + KEYCODE_DPAD_CENTER -> { + if (isKeyDown) activate(state.selectedKey) true - } else { - false + } + + KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { + if (isKeyDown) { + if (event.nativeKeyEvent.device?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { + onSubmit() + } else { + activate(state.selectedKey) + } + } + true + } + + KEYCODE_DEL -> { + if (isKeyDown) onDelete() + true + } + + KEYCODE_SPACE -> { + if (isKeyDown) onSpace() + true + } + + else -> { + val nativeEvent = event.nativeKeyEvent + if (nativeEvent.isCtrlPressed || nativeEvent.isMetaPressed) { + return@onPreviewKeyEvent false + } + val unicodeChar = nativeEvent.getUnicodeChar(nativeEvent.metaState) + val isPrintable = unicodeChar != 0 && + (unicodeChar and KeyCharacterMap.COMBINING_ACCENT) == 0 && + !Character.isISOControl(unicodeChar) + if (isPrintable) { + if (isKeyDown) onCharacter(unicodeChar.toChar()) + true + } else { + false + } } } } + .focusable(), + ) { + state.keys.forEachIndexed { index, key -> + TvSearchKeyCap( + key = key, + selected = state.isSelected(index), + isSymbolsPage = state.isSymbolsPage, + ) } - .focusable(), - ) { - state.keys.forEachIndexed { index, key -> - TvSearchKeyCap( - key = key, - selected = state.isSelected(index), - isSymbolsPage = state.isSymbolsPage, - ) } } } @@ -219,7 +263,6 @@ private fun TvSearchKeyCap( else -> Color.Transparent } val contentColor = if (selected) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textSecondary - val deleteLabel = stringResource(LR.string.delete) val shape = RoundedCornerShape(8.dp) val sizeModifier = when (key) { is TvSearchKey.Character -> Modifier.width(24.dp).height(48.dp).clip(shape).background(background) @@ -253,7 +296,6 @@ private fun TvSearchKeyCap( text = "⌫", style = MaterialTheme.tvTypography.title3, color = contentColor, - modifier = Modifier.semantics { contentDescription = deleteLabel }, ) TvSearchKey.TogglePage -> Text( diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt new file mode 100644 index 00000000000..35372cb3c60 --- /dev/null +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt @@ -0,0 +1,82 @@ +package au.com.shiftyjelly.pocketcasts.search + +import android.view.KeyEvent.KEYCODE_DPAD_LEFT +import android.view.KeyEvent.KEYCODE_DPAD_RIGHT +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TvSearchKeyboardStateTest { + + private val state = TvSearchKeyboardState() + + @Test + fun `initial state selects the first letter on the letters page`() { + assertFalse(state.isSymbolsPage) + assertEquals(TvSearchKey.Character('a'), state.selectedKey) + } + + @Test + fun `dpad right moves to the next key and consumes the event`() { + val consumed = state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) + assertTrue(consumed) + assertEquals(TvSearchKey.Character('b'), state.selectedKey) + } + + @Test + fun `dpad left walks onto the leading Space and Toggle keys then stops`() { + state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true) + assertEquals(TvSearchKey.Space, state.selectedKey) + state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true) + assertEquals(TvSearchKey.TogglePage, state.selectedKey) + + val consumedAtStart = state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true) + assertFalse(consumedAtStart) + assertEquals(TvSearchKey.TogglePage, state.selectedKey) + } + + @Test + fun `dpad right stops on the trailing Delete key`() { + repeat(40) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) } + assertEquals(TvSearchKey.Delete, state.selectedKey) + + val consumedAtEnd = state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) + assertFalse(consumedAtEnd) + assertEquals(TvSearchKey.Delete, state.selectedKey) + } + + @Test + fun `key up returns the consumed state then resets`() { + state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) + assertTrue(state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = false)) + assertFalse(state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = false)) + } + + @Test + fun `toggle page swaps to the symbols page and back`() { + state.togglePage() + assertTrue(state.isSymbolsPage) + assertEquals(TvSearchKey.Character('0'), state.selectedKey) + + state.togglePage() + assertFalse(state.isSymbolsPage) + assertEquals(TvSearchKey.Character('a'), state.selectedKey) + } + + @Test + fun `toggle page clamps a high selection onto the shorter symbols page`() { + repeat(23) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) } + state.togglePage() + assertTrue(state.isSymbolsPage) + assertEquals(TvSearchKey.Delete, state.selectedKey) + } + + @Test + fun `isSelected only reports the selection while focused`() { + assertFalse(state.isSelected(state.selectedIndex)) + state.onFocusChanged(true) + assertTrue(state.isSelected(state.selectedIndex)) + assertFalse(state.isSelected(state.selectedIndex + 1)) + } +} From 5f479d36dc1b9be320115de4e5827cb73b4c2408 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 14:43:53 +0200 Subject: [PATCH 03/28] Address PR comments --- .../pocketcasts/search/TvSearchKeyboard.kt | 145 ++++++++++-------- 1 file changed, 84 insertions(+), 61 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt index 6ea33dcfcf4..a5cf5d02bb5 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt @@ -2,12 +2,14 @@ package au.com.shiftyjelly.pocketcasts.search import android.view.InputDevice import android.view.KeyCharacterMap +import android.view.KeyEvent import android.view.KeyEvent.KEYCODE_DEL import android.view.KeyEvent.KEYCODE_DPAD_CENTER import android.view.KeyEvent.KEYCODE_DPAD_LEFT import android.view.KeyEvent.KEYCODE_DPAD_RIGHT import android.view.KeyEvent.KEYCODE_ENTER import android.view.KeyEvent.KEYCODE_NUMPAD_ENTER +import android.view.KeyEvent.KEYCODE_SEARCH import android.view.KeyEvent.KEYCODE_SPACE import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -51,6 +53,8 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextAlign +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.LayoutDirection @@ -142,7 +146,7 @@ internal fun TvSearchKeyboard( onDelete: () -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, - autoFocus: Boolean = true, + autoFocus: Boolean = false, state: TvSearchKeyboardState = rememberTvSearchKeyboardState(), ) { val focusRequester = remember { FocusRequester() } @@ -175,81 +179,94 @@ internal fun TvSearchKeyboard( ) } - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .focusRequester(focusRequester) - .onFocusChanged { state.onFocusChanged(it.isFocused) } - .semantics { - contentDescription = keyboardDescription - stateDescription = selectedKeyLabel - liveRegion = LiveRegionMode.Polite - } - .onPreviewKeyEvent { event -> - val keyCode = event.key.nativeKeyCode - val isKeyDown = event.type == KeyEventType.KeyDown - when (keyCode) { - KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) - - KEYCODE_DPAD_CENTER -> { - if (isKeyDown) activate(state.selectedKey) - true - } + Box(modifier = modifier) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .focusRequester(focusRequester) + .onFocusChanged { state.onFocusChanged(it.isFocused) } + .semantics(mergeDescendants = true) { + contentDescription = keyboardDescription + stateDescription = selectedKeyLabel + liveRegion = LiveRegionMode.Polite + } + .onPreviewKeyEvent { event -> + val keyCode = event.key.nativeKeyCode + val isKeyDown = event.type == KeyEventType.KeyDown + when (keyCode) { + KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) + + KEYCODE_DPAD_CENTER -> { + if (isKeyDown) activate(state.selectedKey) + true + } - KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { - if (isKeyDown) { - if (event.nativeKeyEvent.device?.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC) { - onSubmit() - } else { - activate(state.selectedKey) + KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { + if (isKeyDown) { + if (event.nativeKeyEvent.isFromPhysicalKeyboard()) { + onSubmit() + } else { + activate(state.selectedKey) + } } + true } - true - } - - KEYCODE_DEL -> { - if (isKeyDown) onDelete() - true - } - KEYCODE_SPACE -> { - if (isKeyDown) onSpace() - true - } + KEYCODE_SEARCH -> { + if (isKeyDown) onSubmit() + true + } - else -> { - val nativeEvent = event.nativeKeyEvent - if (nativeEvent.isCtrlPressed || nativeEvent.isMetaPressed) { - return@onPreviewKeyEvent false + KEYCODE_DEL -> { + if (isKeyDown) onDelete() + true } - val unicodeChar = nativeEvent.getUnicodeChar(nativeEvent.metaState) - val isPrintable = unicodeChar != 0 && - (unicodeChar and KeyCharacterMap.COMBINING_ACCENT) == 0 && - !Character.isISOControl(unicodeChar) - if (isPrintable) { - if (isKeyDown) onCharacter(unicodeChar.toChar()) + + KEYCODE_SPACE -> { + if (isKeyDown) onSpace() true - } else { - false + } + + else -> { + val nativeEvent = event.nativeKeyEvent + if (nativeEvent.isCtrlPressed || nativeEvent.isMetaPressed) { + return@onPreviewKeyEvent false + } + val unicodeChar = nativeEvent.getUnicodeChar(nativeEvent.metaState) + val isPrintable = unicodeChar != 0 && + (unicodeChar and KeyCharacterMap.COMBINING_ACCENT) == 0 && + !Character.isISOControl(unicodeChar) + if (isPrintable) { + if (isKeyDown) onCharacter(unicodeChar.toChar()) + true + } else { + false + } } } } + .focusable(), + ) { + state.keys.forEachIndexed { index, key -> + TvSearchKeyCap( + key = key, + selected = state.isSelected(index), + isSymbolsPage = state.isSymbolsPage, + ) } - .focusable(), - ) { - state.keys.forEachIndexed { index, key -> - TvSearchKeyCap( - key = key, - selected = state.isSelected(index), - isSymbolsPage = state.isSymbolsPage, - ) } } } } +private fun KeyEvent.isFromPhysicalKeyboard(): Boolean { + val device = device ?: return false + return device.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC && + (source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD +} + @Composable private fun TvSearchKeyCap( key: TvSearchKey, @@ -269,7 +286,9 @@ private fun TvSearchKeyCap( TvSearchKey.Delete -> Modifier.width(42.dp).height(48.dp).clip(shape).background(background) - TvSearchKey.Space, TvSearchKey.TogglePage -> + TvSearchKey.Space -> Modifier.width(60.dp).clip(shape).background(background).padding(vertical = 6.dp) + + TvSearchKey.TogglePage -> Modifier.clip(shape).background(background).padding(horizontal = 12.dp, vertical = 6.dp) } @@ -290,6 +309,9 @@ private fun TvSearchKeyCap( text = stringResource(LR.string.tv_search_key_space), style = MaterialTheme.tvTypography.caption1, color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, ) TvSearchKey.Delete -> Text( @@ -331,6 +353,7 @@ private fun TvSearchKeyboardPreview() { onSpace = { query += ' ' }, onDelete = { query = query.dropLast(1) }, onSubmit = {}, + autoFocus = true, ) } } From fc3c625b9101d69192641079439891bcb4938d3a Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 15:05:54 +0200 Subject: [PATCH 04/28] Activate the highlighted key on Enter instead of submitting --- .../pocketcasts/search/TvSearchKeyboard.kt | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt index a5cf5d02bb5..24f2f492022 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt @@ -1,8 +1,6 @@ package au.com.shiftyjelly.pocketcasts.search -import android.view.InputDevice import android.view.KeyCharacterMap -import android.view.KeyEvent import android.view.KeyEvent.KEYCODE_DEL import android.view.KeyEvent.KEYCODE_DPAD_CENTER import android.view.KeyEvent.KEYCODE_DPAD_LEFT @@ -198,22 +196,11 @@ internal fun TvSearchKeyboard( when (keyCode) { KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown) - KEYCODE_DPAD_CENTER -> { + KEYCODE_DPAD_CENTER, KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { if (isKeyDown) activate(state.selectedKey) true } - KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> { - if (isKeyDown) { - if (event.nativeKeyEvent.isFromPhysicalKeyboard()) { - onSubmit() - } else { - activate(state.selectedKey) - } - } - true - } - KEYCODE_SEARCH -> { if (isKeyDown) onSubmit() true @@ -261,12 +248,6 @@ internal fun TvSearchKeyboard( } } -private fun KeyEvent.isFromPhysicalKeyboard(): Boolean { - val device = device ?: return false - return device.keyboardType == InputDevice.KEYBOARD_TYPE_ALPHABETIC && - (source and InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD -} - @Composable private fun TvSearchKeyCap( key: TvSearchKey, From 1c1dd3dfc3b14b0d3efb7cb501f27e473580f0ac Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 20:30:58 +0200 Subject: [PATCH 05/28] Address PR comments --- .../pocketcasts/search/TvSearchKeyboard.kt | 2 +- .../search/TvSearchKeyboardStateTest.kt | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt index 24f2f492022..2af7b698656 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt @@ -177,7 +177,7 @@ internal fun TvSearchKeyboard( ) } - Box(modifier = modifier) { + Box(modifier = modifier, propagateMinConstraints = true) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { Row( horizontalArrangement = Arrangement.spacedBy(2.dp), diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt index 35372cb3c60..47457f7ad68 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt @@ -1,7 +1,9 @@ package au.com.shiftyjelly.pocketcasts.search +import android.view.KeyEvent.KEYCODE_DPAD_DOWN import android.view.KeyEvent.KEYCODE_DPAD_LEFT import android.view.KeyEvent.KEYCODE_DPAD_RIGHT +import android.view.KeyEvent.KEYCODE_DPAD_UP import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -66,12 +68,22 @@ class TvSearchKeyboardStateTest { @Test fun `toggle page clamps a high selection onto the shorter symbols page`() { - repeat(23) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) } + assertEquals(29, state.keys.size) + repeat(40) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) } + assertEquals(TvSearchKey.Delete, state.selectedKey) + state.togglePage() assertTrue(state.isSymbolsPage) + assertEquals(20, state.keys.size) assertEquals(TvSearchKey.Delete, state.selectedKey) } + @Test + fun `vertical dpad keys are not consumed so navigation can leave the keyboard`() { + assertFalse(state.handleDpadDirection(KEYCODE_DPAD_UP, isKeyDown = true)) + assertFalse(state.handleDpadDirection(KEYCODE_DPAD_DOWN, isKeyDown = true)) + } + @Test fun `isSelected only reports the selection while focused`() { assertFalse(state.isSelected(state.selectedIndex)) From 8529c0d00d591e422bf5b3ee690372d9153bb6eb Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 12:46:13 +0200 Subject: [PATCH 06/28] Add TV search query display field --- .../pocketcasts/search/TvSearchField.kt | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt 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..4d518c1fea1 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt @@ -0,0 +1,92 @@ +package au.com.shiftyjelly.pocketcasts.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +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, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + LaunchedEffect(query) { scrollState.scrollTo(scrollState.maxValue) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = painterResource(IR.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.tvColors.textSecondary, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.width(20.dp)) + if (query.isEmpty()) { + Text( + text = stringResource(LR.string.search), + style = MaterialTheme.tvTypography.title2, + color = MaterialTheme.tvColors.textSecondary, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.horizontalScroll(scrollState), + ) { + Text( + text = query, + style = MaterialTheme.tvTypography.title2, + color = MaterialTheme.tvColors.textPrimary, + maxLines = 1, + softWrap = false, + ) + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .width(3.dp) + .height(36.dp) + .background(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 = "huberman") + } + } +} From 00afaa84b145a8ee3adf4183ff4eb584aa12164e Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 12:47:28 +0200 Subject: [PATCH 07/28] Show the TV search keyboard screen on the Search tab --- .../pocketcasts/home/TvScaffold.kt | 5 +- .../pocketcasts/search/TvSearchScreen.kt | 55 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt 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/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt new file mode 100644 index 00000000000..df506101945 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -0,0 +1,55 @@ +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors + +@Composable +fun TvSearchScreen( + modifier: Modifier = Modifier, +) { + var query by rememberSaveable { mutableStateOf("") } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 48.dp), + ) { + Spacer(modifier = Modifier.height(40.dp)) + TvSearchField(query = query) + Spacer(modifier = Modifier.height(40.dp)) + TvSearchKeyboard( + onCharacter = { query += it }, + onSpace = { query += ' ' }, + onDelete = { query = query.dropLast(1) }, + onSubmit = {}, + autoFocus = true, + ) + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSearchScreenPreview() { + TvTheme { + Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.tvColors.backgroundSunken)) { + TvSearchScreen() + } + } +} From bba5ffef09e4cfd79bbc0451eca8a4a3d1799b9e Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 16:26:00 +0200 Subject: [PATCH 08/28] Add TV category tile component --- .../pocketcasts/component/TvCategoryTile.kt | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt 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..793d26be6e2 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt @@ -0,0 +1,84 @@ +package au.com.shiftyjelly.pocketcasts.component + +import androidx.compose.foundation.background +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.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.onFocusChanged +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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, +) { + var isFocused by remember { mutableStateOf(false) } + val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary + + TvTile( + onClick = onClick, + modifier = modifier + .width(280.dp) + .height(128.dp) + .onFocusChanged { isFocused = it.isFocused }, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + AsyncImage( + model = category.icon, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = category.name, + style = MaterialTheme.tvTypography.headline, + color = contentColor, + ) + } + } +} + +@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 = {}, + ) + } + } +} From 14fb5d9b8118fc77b3fbc1705bd8b89f14b04675 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 16:41:11 +0200 Subject: [PATCH 09/28] Load browse categories in a TV search view model --- .../pocketcasts/search/TvSearchViewModel.kt | 41 +++++++++++++++++ .../search/TvSearchViewModelTest.kt | 45 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt create mode 100644 tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt 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 = "") +} From 8cdb93fe890a22f4c061645bcb81d3214e04a9ec Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 16:41:11 +0200 Subject: [PATCH 10/28] Show the browse categories row below the search keyboard --- .../src/main/res/values/strings.xml | 1 + .../pocketcasts/search/TvSearchScreen.kt | 89 ++++++++++++++++--- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 6cd64538726..203e447f250 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -253,6 +253,7 @@ Because you liked Podcast Keep Listening On-screen keyboard + Browse categories Space 123 ABC 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 index df506101945..51b083b9aa9 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -3,44 +3,99 @@ 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, + onCharacter = { query += it }, + onSpace = { query += ' ' }, + onDelete = { query = query.dropLast(1) }, + modifier = modifier, + ) +} + +@Composable +private fun TvSearchContent( + query: String, + categories: List, + onCharacter: (Char) -> Unit, + onSpace: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { Column( modifier = modifier .fillMaxSize() - .padding(horizontal = 48.dp), + .verticalScroll(rememberScrollState()), ) { - Spacer(modifier = Modifier.height(40.dp)) - TvSearchField(query = query) - Spacer(modifier = Modifier.height(40.dp)) - TvSearchKeyboard( - onCharacter = { query += it }, - onSpace = { query += ' ' }, - onDelete = { query = query.dropLast(1) }, - onSubmit = {}, - autoFocus = true, - ) + Column(modifier = Modifier.padding(horizontal = 48.dp)) { + Spacer(modifier = Modifier.height(40.dp)) + TvSearchField(query = query) + Spacer(modifier = Modifier.height(40.dp)) + TvSearchKeyboard( + onCharacter = onCharacter, + onSpace = onSpace, + onDelete = onDelete, + onSubmit = {}, + autoFocus = true, + ) + } + + if (categories.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + 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)) + } } } @@ -49,7 +104,17 @@ fun TvSearchScreen( private fun TvSearchScreenPreview() { TvTheme { Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.tvColors.backgroundSunken)) { - TvSearchScreen() + 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 = ""), + ), + onCharacter = {}, + onSpace = {}, + onDelete = {}, + ) } } } From 498977159e1581419fa9646aa71c9d612e208860 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 17:40:17 +0200 Subject: [PATCH 11/28] Refine category tile sizing and resting background --- .../pocketcasts/component/TvCategoryTile.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 index 793d26be6e2..8538079c552 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.graphics.ColorFilter 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 @@ -40,6 +41,10 @@ fun TvCategoryTile( TvTile( onClick = onClick, + colors = CardDefaults.colors( + containerColor = MaterialTheme.tvColors.backgroundOverlay, + focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay, + ), modifier = modifier .width(280.dp) .height(128.dp) @@ -54,12 +59,12 @@ fun TvCategoryTile( model = category.icon, contentDescription = null, colorFilter = ColorFilter.tint(contentColor), - modifier = Modifier.size(40.dp), + modifier = Modifier.size(28.dp), ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) Text( text = category.name, - style = MaterialTheme.tvTypography.headline, + style = MaterialTheme.tvTypography.body, color = contentColor, ) } From 67f28cfafe92ed241b5575b4abba24401b9d9d0e Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 20:33:31 +0200 Subject: [PATCH 12/28] Address PR comments --- .../java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4d518c1fea1..be06edc050b 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt @@ -34,7 +34,7 @@ internal fun TvSearchField( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - LaunchedEffect(query) { scrollState.scrollTo(scrollState.maxValue) } + LaunchedEffect(scrollState.maxValue) { scrollState.scrollTo(scrollState.maxValue) } Row( verticalAlignment = Alignment.CenterVertically, From ae186efd0394dc4957afebcabf852a68257888c5 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Wed, 12 Aug 2026 09:27:57 +0200 Subject: [PATCH 13/28] Address PR comments --- .../pocketcasts/component/TvCategoryTile.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 index 8538079c552..da7086259e8 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt @@ -1,6 +1,8 @@ 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 @@ -11,13 +13,11 @@ 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.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.onFocusChanged 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 @@ -36,7 +36,8 @@ fun TvCategoryTile( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - var isFocused by remember { mutableStateOf(false) } + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary TvTile( @@ -45,10 +46,10 @@ fun TvCategoryTile( containerColor = MaterialTheme.tvColors.backgroundOverlay, focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay, ), + interactionSource = interactionSource, modifier = modifier .width(280.dp) - .height(128.dp) - .onFocusChanged { isFocused = it.isFocused }, + .height(128.dp), ) { Column( modifier = Modifier.fillMaxSize(), @@ -66,6 +67,9 @@ fun TvCategoryTile( text = category.name, style = MaterialTheme.tvTypography.body, color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 12.dp), ) } } From fcaf23b548e57e5eabc168f326c0db9d188fcc95 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:04:13 +0200 Subject: [PATCH 14/28] Extract shared TV discover models and feed loader --- .../discover/TvDiscoverFeedLoader.kt | 90 +++++++++++ .../pocketcasts/discover/TvDiscoverModels.kt | 52 ++++++ .../pocketcasts/home/TvHomeScreen.kt | 52 ++++-- .../pocketcasts/home/TvHomeViewModel.kt | 153 ++---------------- .../pocketcasts/home/TvHomeViewModelTest.kt | 32 ++-- 5 files changed, 216 insertions(+), 163 deletions(-) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt new file mode 100644 index 00000000000..1b77a06ffde --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -0,0 +1,90 @@ +package au.com.shiftyjelly.pocketcasts.discover + +import android.content.Context +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow +import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle +import au.com.shiftyjelly.pocketcasts.servers.model.ListType +import au.com.shiftyjelly.pocketcasts.servers.model.transformWithRegion +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class TvDiscoverFeedLoader @Inject constructor( + private val listRepository: ListRepository, + private val settings: Settings, + @ApplicationContext private val context: Context, +) { + suspend fun load(isLoggedIn: Boolean): List = coroutineScope { + val discover = listRepository.getDiscoverFeed() + val region = discover.regions[settings.discoverCountryCode.value] + ?: discover.regions[discover.defaultRegionCode] + ?: error("Could not resolve discover region") + val replacements = mapOf( + discover.regionCodeToken to region.code, + discover.regionNameToken to region.name, + ) + + discover.layout + .transformWithRegion(region, replacements, context.resources) + .filter { it.categoryId == null } // Rows with a category ID are sponsored ads for the category pages. + .filter { isLoggedIn || it.authenticated != true } + .map { row -> async { loadRow(row) } } + .awaitAll() + .filterNotNull() + } + + private suspend fun loadRow(row: DiscoverRow): TvDiscoverRow? { + return when (row.type) { + is ListType.PodcastList -> loadPodcastsRow(row) + is ListType.EpisodeList -> loadEpisodesRow(row) + is ListType.Categories, is ListType.Unknown -> null + } + } + + private suspend fun loadPodcastsRow(row: DiscoverRow): TvDiscoverRow? { + val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null + val podcasts = feed.podcasts.orEmpty() + .distinctBy(DiscoverPodcast::uuid) + .map { it.toTvDiscoverPodcast(isSponsored = row.sponsored) } + if (podcasts.isEmpty()) return null + val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title + return when (row.displayStyle) { + is DisplayStyle.Carousel -> TvDiscoverRow.FeaturedPodcasts(id = row.rowId(), title = title, podcasts = podcasts) + is DisplayStyle.SinglePodcast -> TvDiscoverRow.SinglePodcast(id = row.rowId(), title = title, podcasts = podcasts) + else -> TvDiscoverRow.Podcasts(id = row.rowId(), title = title, podcasts = podcasts) + } + } + + private suspend fun loadEpisodesRow(row: DiscoverRow): TvDiscoverRow? { + val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null + val episodes = feed.episodes.orEmpty() + .distinctBy(DiscoverEpisode::uuid) + .map { episode -> + TvDiscoverEpisode( + episodeUuid = episode.uuid, + episodeTitle = episode.title.orEmpty(), + podcastUuid = episode.podcast_uuid, + podcastTitle = episode.podcast_title.orEmpty(), + ) + } + if (episodes.isEmpty()) return null + val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title + return TvDiscoverRow.Episodes(id = row.rowId(), title = title, episodes = episodes) + } + + private fun DiscoverRow.rowId() = listUuid ?: id ?: title + + private fun DiscoverPodcast.toTvDiscoverPodcast(isSponsored: Boolean) = TvDiscoverPodcast( + uuid = uuid, + title = title.orEmpty(), + author = author.orEmpty(), + description = description.orEmpty(), + isSponsored = isSponsored, + ) +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt new file mode 100644 index 00000000000..e8ea58e46ab --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt @@ -0,0 +1,52 @@ +package au.com.shiftyjelly.pocketcasts.discover + +import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage + +sealed interface TvDiscoverRow { + val id: String + val title: String + + data class FeaturedPodcasts( + override val id: String, + override val title: String, + val podcasts: List, + ) : TvDiscoverRow + + data class SinglePodcast( + override val id: String, + override val title: String, + val podcasts: List, + ) : TvDiscoverRow + + data class Podcasts( + override val id: String, + override val title: String, + val podcasts: List, + ) : TvDiscoverRow + + data class Episodes( + override val id: String, + override val title: String, + val episodes: List, + ) : TvDiscoverRow +} + +data class TvDiscoverPodcast( + val uuid: String, + val title: String, + val author: String, + val description: String, + val isSponsored: Boolean = false, +) { + val artworkUrl: String = PodcastImage.getMediumArtworkUrl(uuid) +} + +data class TvDiscoverEpisode( + val episodeUuid: String, + val episodeTitle: String, + val podcastUuid: String, + val podcastTitle: String, +) { + val thumbnailUrl: String = PodcastImage.getArtworkUrl(size = 960, uuid = podcastUuid, isWearOS = false) + val podcastArtworkUrl: String = PodcastImage.getArtworkUrl(size = 200, uuid = podcastUuid, isWearOS = false) +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt index aa136dceb59..72e6a795159 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt @@ -41,6 +41,9 @@ import au.com.shiftyjelly.pocketcasts.component.TvRow import au.com.shiftyjelly.pocketcasts.component.TvVideoTile import au.com.shiftyjelly.pocketcasts.component.tvFocusInactiveWhen import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.podcasts.TvPodcastDetailsScreen import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight @@ -101,7 +104,7 @@ private fun TvHomeContent( uiState: TvHomeUiState, onRetry: () -> Unit, onOpenPodcast: (String) -> Unit, - onPlayEpisode: (TvHomeEpisode) -> Unit, + onPlayEpisode: (TvDiscoverEpisode) -> Unit, modifier: Modifier = Modifier, restoreFocusTrigger: Int = 0, ) { @@ -149,9 +152,9 @@ private fun TvHomeError( @Composable private fun TvHomeRows( - rows: List, + rows: List, onOpenPodcast: (String) -> Unit, - onPlayEpisode: (TvHomeEpisode) -> Unit, + onPlayEpisode: (TvDiscoverEpisode) -> Unit, modifier: Modifier = Modifier, restoreFocusTrigger: Int = 0, ) { @@ -182,12 +185,12 @@ private fun TvHomeRows( } val rowFocusRequester = rowFocusRequesters[rowIndex] when (row) { - is TvHomeRow.FeaturedPodcasts -> item(key = row.id) { + is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) { TvRow( title = row.title, items = row.podcasts, itemSpacing = 32.dp, - key = TvHomePodcast::uuid, + key = TvDiscoverPodcast::uuid, focusRequester = rowFocusRequester, modifier = rowModifier, ) { podcast -> @@ -202,12 +205,32 @@ private fun TvHomeRows( } } - is TvHomeRow.Episodes -> item(key = row.id) { + is TvDiscoverRow.SinglePodcast -> item(key = row.id) { + TvRow( + title = row.title, + items = row.podcasts, + itemSpacing = 32.dp, + key = TvDiscoverPodcast::uuid, + focusRequester = rowFocusRequester, + modifier = rowModifier, + ) { podcast -> + TvFeaturedTile( + artworkUrl = podcast.artworkUrl, + isSponsored = podcast.isSponsored, + title = podcast.title, + description = podcast.description, + onGoToPodcast = { onOpenPodcast(podcast.uuid) }, + onPlayLastEpisode = {}, + ) + } + } + + is TvDiscoverRow.Episodes -> item(key = row.id) { TvRow( title = row.title, items = row.episodes, itemSpacing = 32.dp, - key = TvHomeEpisode::episodeUuid, + key = TvDiscoverEpisode::episodeUuid, focusRequester = rowFocusRequester, modifier = rowModifier, ) { episode -> @@ -222,11 +245,11 @@ private fun TvHomeRows( } } - is TvHomeRow.Podcasts -> item(key = row.id) { + is TvDiscoverRow.Podcasts -> item(key = row.id) { TvRow( title = row.title, items = row.podcasts, - key = TvHomePodcast::uuid, + key = TvDiscoverPodcast::uuid, focusRequester = rowFocusRequester, modifier = rowModifier, ) { podcast -> @@ -253,16 +276,16 @@ private fun TvHomeContentPreview() { TvHomeContent( uiState = TvHomeUiState.Ready( rows = listOf( - TvHomeRow.FeaturedPodcasts( + TvDiscoverRow.FeaturedPodcasts( id = "featured", title = "Featured", podcasts = (1..3).map { previewPodcast(it) }, ), - TvHomeRow.Episodes( + TvDiscoverRow.Episodes( id = "tv_featured_videos", title = "Made for TV", episodes = (1..6).map { - TvHomeEpisode( + TvDiscoverEpisode( episodeUuid = "episode-$it", episodeTitle = "Episode $it", podcastUuid = "podcast-$it", @@ -270,7 +293,7 @@ private fun TvHomeContentPreview() { ) }, ), - TvHomeRow.Podcasts( + TvDiscoverRow.Podcasts( id = "trending", title = "Trending", podcasts = (1..8).map { previewPodcast(it) }, @@ -300,8 +323,9 @@ private fun TvHomeErrorPreview() { } } -private fun previewPodcast(index: Int) = TvHomePodcast( +private fun previewPodcast(index: Int) = TvDiscoverPodcast( uuid = "podcast-$index", title = "Podcast $index", + author = "Author $index", description = "Description of podcast $index", ) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt index ca84d7e573f..5da7a487f52 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt @@ -4,32 +4,25 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.db.dao.PodcastDao import au.com.shiftyjelly.pocketcasts.models.db.dao.UpNextDao import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.type.SmartRules -import au.com.shiftyjelly.pocketcasts.preferences.Settings -import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage -import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.playlist.PlaylistManager import au.com.shiftyjelly.pocketcasts.repositories.playlist.SmartPlaylistDraft import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager -import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode -import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast -import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow -import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle -import au.com.shiftyjelly.pocketcasts.servers.model.ListType -import au.com.shiftyjelly.pocketcasts.servers.model.transformWithRegion import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -47,11 +40,10 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR @HiltViewModel class TvHomeViewModel @Inject constructor( - private val listRepository: ListRepository, + private val discoverFeedLoader: TvDiscoverFeedLoader, private val playlistManager: PlaylistManager, private val podcastDao: PodcastDao, private val upNextDao: UpNextDao, - private val settings: Settings, private val syncManager: SyncManager, private val episodeManager: EpisodeManager, private val podcastManager: PodcastManager, @@ -93,12 +85,12 @@ class TvHomeViewModel @Inject constructor( } } - private suspend fun loadRows(): List = coroutineScope { + private suspend fun loadRows(): List = coroutineScope { val isLoggedIn = syncManager.isLoggedIn() val localRowsDeferred = async { loadLocalRows(isLoggedIn) } val discoverRowsDeferred = async { try { - Result.success(loadDiscoverRows(isLoggedIn)) + Result.success(discoverFeedLoader.load(isLoggedIn)) } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { @@ -112,29 +104,10 @@ class TvHomeViewModel @Inject constructor( Timber.e(exception, "Failed to load TV discover rows") emptyList() } - (localRows + discoverRows).distinctBy(TvHomeRow::id) + (localRows + discoverRows).distinctBy(TvDiscoverRow::id) } - private suspend fun loadDiscoverRows(isLoggedIn: Boolean): List = coroutineScope { - val discover = listRepository.getDiscoverFeed() - val region = discover.regions[settings.discoverCountryCode.value] - ?: discover.regions[discover.defaultRegionCode] - ?: error("Could not resolve discover region") - val replacements = mapOf( - discover.regionCodeToken to region.code, - discover.regionNameToken to region.name, - ) - - discover.layout - .transformWithRegion(region, replacements, context.resources) - .filter { it.categoryId == null } // Rows with a category ID are sponsored ads for the category pages. - .filter { isLoggedIn || it.authenticated != true } - .map { row -> async { loadRow(row) } } - .awaitAll() - .filterNotNull() - } - - private suspend fun loadLocalRows(isLoggedIn: Boolean): List = coroutineScope { + private suspend fun loadLocalRows(isLoggedIn: Boolean): List = coroutineScope { val upNextEpisodesDeferred = async { upNextDao.getUpNextBaseEpisodes(limit = UP_NEXT_LIMIT + 1).filterIsInstance() } @@ -155,10 +128,10 @@ class TvHomeViewModel @Inject constructor( buildList { upNextEpisodes.firstOrNull()?.let { current -> add( - TvHomeRow.Episodes( + TvDiscoverRow.Episodes( id = KEEP_LISTENING_ROW_ID, title = context.getString(LR.string.tv_home_keep_listening), - episodes = listOf(current.toTvHomeEpisode(podcastTitles)), + episodes = listOf(current.toTvDiscoverEpisode(podcastTitles)), ), ) } @@ -166,19 +139,19 @@ class TvHomeViewModel @Inject constructor( val queue = upNextEpisodes.drop(1) if (queue.isNotEmpty()) { add( - TvHomeRow.Episodes( + TvDiscoverRow.Episodes( id = UP_NEXT_ROW_ID, title = context.getString(LR.string.up_next), - episodes = queue.map { it.toTvHomeEpisode(podcastTitles) }, + episodes = queue.map { it.toTvDiscoverEpisode(podcastTitles) }, ), ) } if (newReleases.isNotEmpty()) { add( - TvHomeRow.Episodes( + TvDiscoverRow.Episodes( id = NEW_RELEASES_ROW_ID, title = context.getString(LR.string.filters_title_new_releases), - episodes = newReleases.map { it.toTvHomeEpisode(podcastTitles) }, + episodes = newReleases.map { it.toTvDiscoverEpisode(podcastTitles) }, ), ) } @@ -186,7 +159,7 @@ class TvHomeViewModel @Inject constructor( } } - fun playEpisode(episode: TvHomeEpisode) { + fun playEpisode(episode: TvDiscoverEpisode) { viewModelScope.launch { try { val found = episodeManager.findByUuid(episode.episodeUuid) @@ -216,63 +189,13 @@ class TvHomeViewModel @Inject constructor( return podcastDao.findAllIn(uuids).associate { it.uuid to it.title } } - private fun PodcastEpisode.toTvHomeEpisode(podcastTitles: Map) = TvHomeEpisode( + private fun PodcastEpisode.toTvDiscoverEpisode(podcastTitles: Map) = TvDiscoverEpisode( episodeUuid = uuid, episodeTitle = title, podcastUuid = podcastUuid, podcastTitle = podcastTitles[podcastUuid].orEmpty(), ) - private suspend fun loadRow(row: DiscoverRow): TvHomeRow? { - return when (row.type) { - is ListType.PodcastList -> loadPodcastsRow(row) - is ListType.EpisodeList -> loadEpisodesRow(row) - is ListType.Categories, is ListType.Unknown -> null - } - } - - private suspend fun loadPodcastsRow(row: DiscoverRow): TvHomeRow? { - val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null - val podcasts = feed.podcasts.orEmpty() - .distinctBy(DiscoverPodcast::uuid) - .map { it.toTvHomePodcast(isSponsored = row.sponsored) } - if (podcasts.isEmpty()) return null - val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title - return when (row.displayStyle) { - is DisplayStyle.Carousel, is DisplayStyle.SinglePodcast -> { - TvHomeRow.FeaturedPodcasts(id = row.rowId(), title = title, podcasts = podcasts) - } - - else -> TvHomeRow.Podcasts(id = row.rowId(), title = title, podcasts = podcasts) - } - } - - private suspend fun loadEpisodesRow(row: DiscoverRow): TvHomeRow? { - val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null - val episodes = feed.episodes.orEmpty() - .distinctBy(DiscoverEpisode::uuid) - .map { episode -> - TvHomeEpisode( - episodeUuid = episode.uuid, - episodeTitle = episode.title.orEmpty(), - podcastUuid = episode.podcast_uuid, - podcastTitle = episode.podcast_title.orEmpty(), - ) - } - if (episodes.isEmpty()) return null - val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title - return TvHomeRow.Episodes(id = row.rowId(), title = title, episodes = episodes) - } - - private fun DiscoverRow.rowId() = listUuid ?: id ?: title - - private fun DiscoverPodcast.toTvHomePodcast(isSponsored: Boolean) = TvHomePodcast( - uuid = uuid, - title = title.orEmpty(), - description = description.orEmpty(), - isSponsored = isSponsored, - ) - companion object { const val KEEP_LISTENING_ROW_ID = "keep_listening" const val UP_NEXT_ROW_ID = "up_next" @@ -290,47 +213,5 @@ class TvHomeViewModel @Inject constructor( sealed interface TvHomeUiState { data object Loading : TvHomeUiState data object Error : TvHomeUiState - data class Ready(val rows: List) : TvHomeUiState -} - -sealed interface TvHomeRow { - val id: String - val title: String - - data class FeaturedPodcasts( - override val id: String, - override val title: String, - val podcasts: List, - ) : TvHomeRow - - data class Podcasts( - override val id: String, - override val title: String, - val podcasts: List, - ) : TvHomeRow - - data class Episodes( - override val id: String, - override val title: String, - val episodes: List, - ) : TvHomeRow -} - -data class TvHomePodcast( - val uuid: String, - val title: String, - val description: String, - val isSponsored: Boolean = false, -) { - val artworkUrl: String = PodcastImage.getMediumArtworkUrl(uuid) -} - -data class TvHomeEpisode( - val episodeUuid: String, - val episodeTitle: String, - val podcastUuid: String, - val podcastTitle: String, -) { - val thumbnailUrl: String = PodcastImage.getArtworkUrl(size = 960, uuid = podcastUuid, isWearOS = false) - val podcastArtworkUrl: String = PodcastImage.getArtworkUrl(size = 200, uuid = podcastUuid, isWearOS = false) + data class Ready(val rows: List) : TvHomeUiState } diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt index c8316124135..4fef3459e4a 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.res.Resources import app.cash.turbine.test import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.db.dao.PodcastDao import au.com.shiftyjelly.pocketcasts.models.db.dao.UpNextDao import au.com.shiftyjelly.pocketcasts.models.entity.Podcast @@ -142,11 +145,11 @@ class TvHomeViewModelTest { listOf("featured", "sponsored-id", "videos-id", "trending", "curated-id"), state.rows.map { it.id }, ) - assertTrue(state.rows[0] is TvHomeRow.FeaturedPodcasts) - assertTrue(state.rows[1] is TvHomeRow.FeaturedPodcasts) - assertTrue(state.rows[2] is TvHomeRow.Episodes) - assertTrue(state.rows[3] is TvHomeRow.Podcasts) - assertTrue(state.rows[4] is TvHomeRow.Podcasts) + assertTrue(state.rows[0] is TvDiscoverRow.FeaturedPodcasts) + assertTrue(state.rows[1] is TvDiscoverRow.SinglePodcast) + assertTrue(state.rows[2] is TvDiscoverRow.Episodes) + assertTrue(state.rows[3] is TvDiscoverRow.Podcasts) + assertTrue(state.rows[4] is TvDiscoverRow.Podcasts) } } @@ -224,7 +227,7 @@ class TvHomeViewModelTest { viewModel.uiState.test { val state = awaitItem() as TvHomeUiState.Ready - val row = state.rows.single() as TvHomeRow.FeaturedPodcasts + val row = state.rows.single() as TvDiscoverRow.SinglePodcast assertTrue(row.podcasts.single().isSponsored) } } @@ -407,7 +410,7 @@ class TvHomeViewModelTest { viewModel.uiState.test { val state = awaitItem() as TvHomeUiState.Ready assertEquals(listOf(TvHomeViewModel.KEEP_LISTENING_ROW_ID), state.rows.map { it.id }) - val row = state.rows.single() as TvHomeRow.Episodes + val row = state.rows.single() as TvDiscoverRow.Episodes assertEquals("Keep Listening", row.title) val rowEpisode = row.episodes.single() assertEquals("episode-1", rowEpisode.episodeUuid) @@ -432,7 +435,7 @@ class TvHomeViewModelTest { viewModel.uiState.test { val state = awaitItem() as TvHomeUiState.Ready - val row = state.rows.single() as TvHomeRow.Episodes + val row = state.rows.single() as TvDiscoverRow.Episodes assertEquals(listOf("episode-1"), row.episodes.map { it.episodeUuid }) } } @@ -475,9 +478,9 @@ class TvHomeViewModelTest { ), state.rows.map { it.id }, ) - val upNextRow = state.rows[1] as TvHomeRow.Episodes + val upNextRow = state.rows[1] as TvDiscoverRow.Episodes assertEquals(listOf("episode-2", "episode-3"), upNextRow.episodes.map { it.episodeUuid }) - val newReleasesRow = state.rows[2] as TvHomeRow.Episodes + val newReleasesRow = state.rows[2] as TvDiscoverRow.Episodes assertEquals(listOf("episode-new"), newReleasesRow.episodes.map { it.episodeUuid }) assertEquals("Podcast Two", newReleasesRow.episodes.single().podcastTitle) } @@ -551,7 +554,7 @@ class TvHomeViewModelTest { } } - private fun homeEpisode() = TvHomeEpisode( + private fun homeEpisode() = TvDiscoverEpisode( episodeUuid = "episode-1", episodeTitle = "Episode", podcastUuid = "podcast-1", @@ -559,11 +562,14 @@ class TvHomeViewModelTest { ) private fun createViewModel() = TvHomeViewModel( - listRepository = listRepository, + discoverFeedLoader = TvDiscoverFeedLoader( + listRepository = listRepository, + settings = settings, + context = context, + ), playlistManager = playlistManager, podcastDao = podcastDao, upNextDao = upNextDao, - settings = settings, syncManager = syncManager, episodeManager = episodeManager, podcastManager = podcastManager, From 1c2d9cca28aa58f041e9a8da302cff26a765e2d5 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:07:37 +0200 Subject: [PATCH 15/28] Extract shared TV discover row rendering --- .../pocketcasts/discover/TvDiscoverRows.kt | 99 +++++++++++++++++++ .../pocketcasts/home/TvHomeScreen.kt | 92 ++--------------- 2 files changed, 107 insertions(+), 84 deletions(-) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt new file mode 100644 index 00000000000..46dfd4bbf55 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt @@ -0,0 +1,99 @@ +package au.com.shiftyjelly.pocketcasts.discover + +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import au.com.shiftyjelly.pocketcasts.component.TvFeaturedTile +import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile +import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults +import au.com.shiftyjelly.pocketcasts.component.TvRow +import au.com.shiftyjelly.pocketcasts.component.TvVideoTile + +fun LazyListScope.tvDiscoverRow( + row: TvDiscoverRow, + onOpenPodcast: (String) -> Unit, + onPlayEpisode: (TvDiscoverEpisode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester? = null, +) { + when (row) { + is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) { + TvRow( + title = row.title, + items = row.podcasts, + itemSpacing = 32.dp, + key = TvDiscoverPodcast::uuid, + focusRequester = focusRequester, + modifier = modifier, + ) { podcast -> + TvFeaturedTile( + artworkUrl = podcast.artworkUrl, + isSponsored = podcast.isSponsored, + title = podcast.title, + description = podcast.description, + onGoToPodcast = { onOpenPodcast(podcast.uuid) }, + onPlayLastEpisode = {}, + ) + } + } + + is TvDiscoverRow.SinglePodcast -> item(key = row.id) { + TvRow( + title = row.title, + items = row.podcasts, + itemSpacing = 32.dp, + key = TvDiscoverPodcast::uuid, + focusRequester = focusRequester, + modifier = modifier, + ) { podcast -> + TvFeaturedTile( + artworkUrl = podcast.artworkUrl, + isSponsored = podcast.isSponsored, + title = podcast.title, + description = podcast.description, + onGoToPodcast = { onOpenPodcast(podcast.uuid) }, + onPlayLastEpisode = {}, + ) + } + } + + is TvDiscoverRow.Episodes -> item(key = row.id) { + TvRow( + title = row.title, + items = row.episodes, + itemSpacing = 32.dp, + key = TvDiscoverEpisode::episodeUuid, + focusRequester = focusRequester, + modifier = modifier, + ) { episode -> + TvVideoTile( + thumbnailUrl = episode.thumbnailUrl, + podcastArtworkUrl = episode.podcastArtworkUrl, + podcastTitle = episode.podcastTitle, + episodeTitle = episode.episodeTitle, + onPlayEpisode = { onPlayEpisode(episode) }, + onGoToPodcast = { onOpenPodcast(episode.podcastUuid) }, + ) + } + } + + is TvDiscoverRow.Podcasts -> item(key = row.id) { + TvRow( + title = row.title, + items = row.podcasts, + key = TvDiscoverPodcast::uuid, + focusRequester = focusRequester, + modifier = modifier, + ) { podcast -> + TvPodcastTile( + artworkUrl = podcast.artworkUrl, + podcastTitle = podcast.title, + onClick = { onOpenPodcast(podcast.uuid) }, + imageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth), + ) + } + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt index 72e6a795159..59a4d1eaa0f 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt @@ -8,7 +8,6 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,16 +33,12 @@ import androidx.tv.material3.Text import au.com.shiftyjelly.pocketcasts.component.LocalOpenNowPlaying import au.com.shiftyjelly.pocketcasts.component.LocalTvToastHostState import au.com.shiftyjelly.pocketcasts.component.TvDetailOverlay -import au.com.shiftyjelly.pocketcasts.component.TvFeaturedTile -import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile -import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults -import au.com.shiftyjelly.pocketcasts.component.TvRow -import au.com.shiftyjelly.pocketcasts.component.TvVideoTile import au.com.shiftyjelly.pocketcasts.component.tvFocusInactiveWhen import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow +import au.com.shiftyjelly.pocketcasts.discover.tvDiscoverRow import au.com.shiftyjelly.pocketcasts.podcasts.TvPodcastDetailsScreen import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight @@ -184,84 +179,13 @@ private fun TvHomeRows( } } val rowFocusRequester = rowFocusRequesters[rowIndex] - when (row) { - is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) { - TvRow( - title = row.title, - items = row.podcasts, - itemSpacing = 32.dp, - key = TvDiscoverPodcast::uuid, - focusRequester = rowFocusRequester, - modifier = rowModifier, - ) { podcast -> - TvFeaturedTile( - artworkUrl = podcast.artworkUrl, - isSponsored = podcast.isSponsored, - title = podcast.title, - description = podcast.description, - onGoToPodcast = { onOpenPodcast(podcast.uuid) }, - onPlayLastEpisode = {}, - ) - } - } - - is TvDiscoverRow.SinglePodcast -> item(key = row.id) { - TvRow( - title = row.title, - items = row.podcasts, - itemSpacing = 32.dp, - key = TvDiscoverPodcast::uuid, - focusRequester = rowFocusRequester, - modifier = rowModifier, - ) { podcast -> - TvFeaturedTile( - artworkUrl = podcast.artworkUrl, - isSponsored = podcast.isSponsored, - title = podcast.title, - description = podcast.description, - onGoToPodcast = { onOpenPodcast(podcast.uuid) }, - onPlayLastEpisode = {}, - ) - } - } - - is TvDiscoverRow.Episodes -> item(key = row.id) { - TvRow( - title = row.title, - items = row.episodes, - itemSpacing = 32.dp, - key = TvDiscoverEpisode::episodeUuid, - focusRequester = rowFocusRequester, - modifier = rowModifier, - ) { episode -> - TvVideoTile( - thumbnailUrl = episode.thumbnailUrl, - podcastArtworkUrl = episode.podcastArtworkUrl, - podcastTitle = episode.podcastTitle, - episodeTitle = episode.episodeTitle, - onPlayEpisode = { onPlayEpisode(episode) }, - onGoToPodcast = { onOpenPodcast(episode.podcastUuid) }, - ) - } - } - - is TvDiscoverRow.Podcasts -> item(key = row.id) { - TvRow( - title = row.title, - items = row.podcasts, - key = TvDiscoverPodcast::uuid, - focusRequester = rowFocusRequester, - modifier = rowModifier, - ) { podcast -> - TvPodcastTile( - artworkUrl = podcast.artworkUrl, - podcastTitle = podcast.title, - onClick = { onOpenPodcast(podcast.uuid) }, - imageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth), - ) - } - } - } + tvDiscoverRow( + row = row, + onOpenPodcast = onOpenPodcast, + onPlayEpisode = onPlayEpisode, + modifier = rowModifier, + focusRequester = rowFocusRequester, + ) } item { Spacer(modifier = Modifier.height(8.dp)) } From c39e4323b9e36bfb24b95290aad1c4f294803be7 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:08:30 +0200 Subject: [PATCH 16/28] Add sponsored single-podcast tile for TV discover --- .../pocketcasts/component/TvSponsoredTile.kt | 149 ++++++++++++++++++ .../pocketcasts/discover/TvDiscoverRows.kt | 9 +- 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt new file mode 100644 index 00000000000..620636e8b1b --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt @@ -0,0 +1,149 @@ +package au.com.shiftyjelly.pocketcasts.component + +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.Spacer +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.foundation.shape.RoundedCornerShape +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +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.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography +import coil3.compose.AsyncImage +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +@Composable +fun TvSponsoredTile( + artworkUrl: String, + title: String, + author: String, + description: String, + isSponsored: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var isFocused by remember { mutableStateOf(false) } + val textPrimary = if (isFocused) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textPrimary + val textSecondary = if (isFocused) MaterialTheme.tvColors.textSecondaryActive else MaterialTheme.tvColors.textSecondary + + TvTile( + onClick = onClick, + scale = CardDefaults.scale(focusedScale = 1.05f), + shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), + colors = CardDefaults.colors( + containerColor = MaterialTheme.tvColors.backgroundSunken, + focusedContainerColor = MaterialTheme.tvColors.backgroundActive, + ), + modifier = modifier.onFocusChanged { isFocused = it.isFocused }, + ) { + Row( + modifier = Modifier + .width(642.dp) + .height(200.dp) + .padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = artworkUrl, + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(152.dp) + .clip(RoundedCornerShape(4.dp)), + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 24.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isSponsored) { + Text( + text = stringResource(LR.string.sponsored), + style = MaterialTheme.tvTypography.body, + color = textPrimary, + ) + if (author.isNotBlank()) { + Text( + text = "·", + style = MaterialTheme.tvTypography.body, + color = textSecondary, + ) + } + } + if (author.isNotBlank()) { + Text( + text = author, + style = MaterialTheme.tvTypography.body, + color = textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = title, + style = MaterialTheme.tvTypography.title2, + color = textPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (description.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, + style = MaterialTheme.tvTypography.body, + color = textSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSponsoredTilePreview() { + TvTheme { + Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) { + TvSponsoredTile( + artworkUrl = "", + title = "The Writer's Voice", + author = "iHeartPodcasts and Kaleidoscope", + description = "New fiction from the pages of The New Yorker, read by its authors.", + isSponsored = true, + onClick = {}, + ) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt index 46dfd4bbf55..844633b22b0 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt @@ -9,6 +9,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvFeaturedTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults import au.com.shiftyjelly.pocketcasts.component.TvRow +import au.com.shiftyjelly.pocketcasts.component.TvSponsoredTile import au.com.shiftyjelly.pocketcasts.component.TvVideoTile fun LazyListScope.tvDiscoverRow( @@ -48,13 +49,13 @@ fun LazyListScope.tvDiscoverRow( focusRequester = focusRequester, modifier = modifier, ) { podcast -> - TvFeaturedTile( + TvSponsoredTile( artworkUrl = podcast.artworkUrl, - isSponsored = podcast.isSponsored, title = podcast.title, + author = podcast.author, description = podcast.description, - onGoToPodcast = { onOpenPodcast(podcast.uuid) }, - onPlayLastEpisode = {}, + isSponsored = podcast.isSponsored, + onClick = { onOpenPodcast(podcast.uuid) }, ) } } From f1b8467915cb918a551b731c71ee2e7e5e26ed84 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:10:32 +0200 Subject: [PATCH 17/28] Load the discover feed in the TV search view model --- .../pocketcasts/search/TvSearchViewModel.kt | 18 +++ .../search/TvSearchViewModelTest.kt | 129 +++++++++++++++++- 2 files changed, 145 insertions(+), 2 deletions(-) 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 index 2c0dea965c9..f95ae268c5a 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -2,8 +2,11 @@ package au.com.shiftyjelly.pocketcasts.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -17,11 +20,16 @@ import timber.log.Timber @HiltViewModel class TvSearchViewModel @Inject constructor( private val listRepository: ListRepository, + private val discoverFeedLoader: TvDiscoverFeedLoader, + private val syncManager: SyncManager, ) : ViewModel() { private val _categories = MutableStateFlow>(emptyList()) val categories: StateFlow> = _categories.asStateFlow() + private val _discoverRows = MutableStateFlow>(emptyList()) + val discoverRows: StateFlow> = _discoverRows.asStateFlow() + init { viewModelScope.launch { _categories.value = try { @@ -33,6 +41,16 @@ class TvSearchViewModel @Inject constructor( emptyList() } } + viewModelScope.launch { + _discoverRows.value = try { + discoverFeedLoader.load(syncManager.isLoggedIn()) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search discover feed") + emptyList() + } + } } companion object { 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 index 3c5c9ff0856..f060e63e160 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -1,7 +1,22 @@ package au.com.shiftyjelly.pocketcasts.search +import android.content.Context +import android.content.res.Resources +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.preferences.UserSetting import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRegion +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow +import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle +import au.com.shiftyjelly.pocketcasts.servers.model.ExpandedStyle +import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed +import au.com.shiftyjelly.pocketcasts.servers.model.ListType import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -10,6 +25,8 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -20,6 +37,19 @@ class TvSearchViewModelTest { val coroutineRule = MainCoroutineRule() private val listRepository = mock() + private val syncManager = mock { + on { isLoggedIn() } doReturn false + } + private val resources = mock() + private val context = mock { + whenever(it.resources).thenReturn(resources) + } + private val discoverCountryCode = mock> { + whenever(it.value).thenReturn("us") + } + private val settings = mock { + whenever(it.discoverCountryCode).thenReturn(discoverCountryCode) + } @Test fun `exposes the loaded browse categories`() = runTest { @@ -27,7 +57,7 @@ class TvSearchViewModelTest { listOf(category(1, "Comedy"), category(2, "True Crime")), ) - val viewModel = TvSearchViewModel(listRepository) + val viewModel = createViewModel() assertEquals(listOf("Comedy", "True Crime"), viewModel.categories.value.map { it.name }) } @@ -36,10 +66,105 @@ class TvSearchViewModelTest { fun `categories are empty when loading fails`() = runTest { whenever(listRepository.getCategoriesList(any())).thenThrow(RuntimeException("Network error")) - val viewModel = TvSearchViewModel(listRepository) + val viewModel = createViewModel() assertTrue(viewModel.categories.value.isEmpty()) } + @Test + fun `exposes the loaded discover rows`() = runTest { + whenever(listRepository.getDiscoverFeed()).thenReturn( + discover(row(id = "trending", title = "Trending", source = "https://lists/trending.json")), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + val rows = viewModel.discoverRows.value + assertEquals(listOf("trending"), rows.map { it.id }) + assertTrue(rows.single() is TvDiscoverRow.Podcasts) + } + + @Test + fun `discover rows are empty when loading fails`() = runTest { + whenever(listRepository.getDiscoverFeed()).thenThrow(RuntimeException("Network error")) + + val viewModel = createViewModel() + + assertTrue(viewModel.discoverRows.value.isEmpty()) + } + + private fun createViewModel() = TvSearchViewModel( + listRepository = listRepository, + discoverFeedLoader = TvDiscoverFeedLoader( + listRepository = listRepository, + settings = settings, + context = context, + ), + syncManager = syncManager, + ) + private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "") + + private fun discover(vararg rows: DiscoverRow) = Discover( + layout = rows.toList(), + regions = mapOf("us" to DiscoverRegion(name = "United States", flag = "flag", code = "us")), + regionCodeToken = "[regionCode]", + regionNameToken = "[regionName]", + defaultRegionCode = "us", + ) + + private fun row( + id: String, + title: String, + source: String, + ) = DiscoverRow( + id = id, + type = ListType.PodcastList, + displayStyle = DisplayStyle.SmallList(), + expandedStyle = ExpandedStyle.PlainList(), + expandedTopItemLabel = null, + title = title, + source = source, + listUuid = id, + categoryId = null, + regions = listOf("us"), + curated = false, + sponsored = false, + authenticated = false, + mostPopularCategoriesId = null, + sponsoredCategoryIds = null, + ) + + private fun podcastFeed(vararg podcastUuids: String) = ListFeed( + title = null, + subtitle = null, + description = null, + shortDescription = null, + date = null, + podcasts = podcastUuids.map { uuid -> + DiscoverPodcast( + uuid = uuid, + title = "Podcast $uuid", + url = null, + author = null, + category = null, + description = null, + language = null, + mediaType = null, + ) + }, + episodes = null, + podroll = null, + collectionImageUrl = null, + collectionRectangleImageUrl = null, + featureImage = null, + headerImageUrl = null, + tintColors = null, + collageImages = null, + webLinkUrl = null, + webLinkTitle = null, + promotion = null, + ) } From 839db4146c4bea76066e8dd1d0c79a30ab72fbe4 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:12:38 +0200 Subject: [PATCH 18/28] Show the discover feed below browse categories on TV search --- .../pocketcasts/discover/TvDiscoverRows.kt | 6 ++ .../pocketcasts/search/TvSearchScreen.kt | 92 ++++++++++++------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt index 844633b22b0..2347b74b537 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt @@ -1,5 +1,6 @@ package au.com.shiftyjelly.pocketcasts.discover +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier @@ -18,6 +19,7 @@ fun LazyListScope.tvDiscoverRow( onPlayEpisode: (TvDiscoverEpisode) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, + contentPadding: PaddingValues = PaddingValues(horizontal = 32.dp), ) { when (row) { is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) { @@ -25,6 +27,7 @@ fun LazyListScope.tvDiscoverRow( title = row.title, items = row.podcasts, itemSpacing = 32.dp, + contentPadding = contentPadding, key = TvDiscoverPodcast::uuid, focusRequester = focusRequester, modifier = modifier, @@ -45,6 +48,7 @@ fun LazyListScope.tvDiscoverRow( title = row.title, items = row.podcasts, itemSpacing = 32.dp, + contentPadding = contentPadding, key = TvDiscoverPodcast::uuid, focusRequester = focusRequester, modifier = modifier, @@ -65,6 +69,7 @@ fun LazyListScope.tvDiscoverRow( title = row.title, items = row.episodes, itemSpacing = 32.dp, + contentPadding = contentPadding, key = TvDiscoverEpisode::episodeUuid, focusRequester = focusRequester, modifier = modifier, @@ -84,6 +89,7 @@ fun LazyListScope.tvDiscoverRow( TvRow( title = row.title, items = row.podcasts, + contentPadding = contentPadding, key = TvDiscoverPodcast::uuid, focusRequester = focusRequester, modifier = modifier, 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 index 51b083b9aa9..2b634acc6b4 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -9,8 +9,7 @@ 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.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,11 +25,15 @@ 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.discover.TvDiscoverRow +import au.com.shiftyjelly.pocketcasts.discover.tvDiscoverRow 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 +private val ContentPadding = PaddingValues(horizontal = 48.dp) + @Composable fun TvSearchScreen( modifier: Modifier = Modifier, @@ -38,10 +41,12 @@ fun TvSearchScreen( ) { var query by rememberSaveable { mutableStateOf("") } val categories by viewModel.categories.collectAsStateWithLifecycle() + val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle() TvSearchContent( query = query, categories = categories, + discoverRows = discoverRows, onCharacter = { query += it }, onSpace = { query += ' ' }, onDelete = { query = query.dropLast(1) }, @@ -53,49 +58,65 @@ fun TvSearchScreen( private fun TvSearchContent( query: String, categories: List, + discoverRows: List, onCharacter: (Char) -> Unit, onSpace: () -> Unit, onDelete: () -> 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) - Spacer(modifier = Modifier.height(40.dp)) - TvSearchKeyboard( - onCharacter = onCharacter, - onSpace = onSpace, - onDelete = onDelete, - onSubmit = {}, - autoFocus = true, - ) + LazyColumn(modifier = modifier.fillMaxSize()) { + item { + Column(modifier = Modifier.padding(ContentPadding)) { + Spacer(modifier = Modifier.height(40.dp)) + TvSearchField(query = query) + Spacer(modifier = Modifier.height(40.dp)) + TvSearchKeyboard( + onCharacter = onCharacter, + onSpace = onSpace, + onDelete = onDelete, + onSubmit = {}, + autoFocus = true, + ) + Spacer(modifier = Modifier.height(24.dp)) + } } if (categories.isNotEmpty()) { - Spacer(modifier = Modifier.height(24.dp)) - 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 = {}) + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(ContentPadding) + .height(1.dp) + .background(MaterialTheme.tvColors.overlayBorder), + ) + } + item { + Spacer(modifier = Modifier.height(24.dp)) + TvRow( + title = stringResource(LR.string.tv_search_browse_categories), + items = categories, + contentPadding = ContentPadding, + key = { it.id }, + ) { category -> + TvCategoryTile(category = category, onClick = {}) + } } - Spacer(modifier = Modifier.height(40.dp)) } + + if (query.isBlank()) { + discoverRows.forEach { row -> + item(key = "discover-spacer-${row.id}") { Spacer(modifier = Modifier.height(24.dp)) } + tvDiscoverRow( + row = row, + onOpenPodcast = {}, + onPlayEpisode = {}, + contentPadding = ContentPadding, + ) + } + } + + item { Spacer(modifier = Modifier.height(40.dp)) } } } @@ -111,6 +132,7 @@ private fun TvSearchScreenPreview() { DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""), DiscoverCategory(id = 3, name = "Fiction", icon = "", source = ""), ), + discoverRows = emptyList(), onCharacter = {}, onSpace = {}, onDelete = {}, From 5aa822bf3c199a27a73bb9d237670a902f564965 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:16:44 +0200 Subject: [PATCH 19/28] Dedup discover rows by id in the shared loader --- .../com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 1b77a06ffde..2508f715956 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -37,6 +37,7 @@ class TvDiscoverFeedLoader @Inject constructor( .map { row -> async { loadRow(row) } } .awaitAll() .filterNotNull() + .distinctBy(TvDiscoverRow::id) } private suspend fun loadRow(row: DiscoverRow): TvDiscoverRow? { From f582185976adb16721ef94f30748bbc6ff1f4ab3 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 18:49:17 +0200 Subject: [PATCH 20/28] Load the dedicated search discover feed on TV search --- .../pocketcasts/repositories/lists/ListRepository.kt | 4 ++++ .../pocketcasts/servers/server/ListWebService.kt | 3 +++ .../pocketcasts/discover/TvDiscoverFeedLoader.kt | 8 ++++++-- .../shiftyjelly/pocketcasts/search/TvSearchViewModel.kt | 2 +- .../pocketcasts/search/TvSearchViewModelTest.kt | 4 ++-- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt index f5687eb59ea..1617a96aaf4 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt @@ -18,6 +18,10 @@ class ListRepository( return listWebService.getDiscoverFeed(platform = platform, version = 3) } + suspend fun getSearchDiscoverFeed(): Discover { + return listWebService.getSearchDiscoverFeed(platform = platform, version = 3) + } + suspend fun getListFeed(url: String, authenticated: Boolean? = false): ListFeed? { return runCatching { if (authenticated == true) { diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt index da535466e5f..63786c83423 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt @@ -12,6 +12,9 @@ interface ListWebService { @GET("/discover/{platform}/content_v{version}.json") suspend fun getDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover + @GET("/discover/{platform}/content_v{version}_search.json") + suspend fun getSearchDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover + @GET suspend fun getListFeed(@Url url: String): ListFeed diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 2508f715956..8e1c152a131 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -3,6 +3,7 @@ package au.com.shiftyjelly.pocketcasts.discover import android.content.Context import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository +import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow @@ -20,8 +21,11 @@ class TvDiscoverFeedLoader @Inject constructor( private val settings: Settings, @ApplicationContext private val context: Context, ) { - suspend fun load(isLoggedIn: Boolean): List = coroutineScope { - val discover = listRepository.getDiscoverFeed() + suspend fun load(isLoggedIn: Boolean): List = buildRows(listRepository.getDiscoverFeed(), isLoggedIn) + + suspend fun loadSearch(isLoggedIn: Boolean): List = buildRows(listRepository.getSearchDiscoverFeed(), isLoggedIn) + + private suspend fun buildRows(discover: Discover, isLoggedIn: Boolean): List = coroutineScope { val region = discover.regions[settings.discoverCountryCode.value] ?: discover.regions[discover.defaultRegionCode] ?: error("Could not resolve discover region") 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 index f95ae268c5a..a376888b23c 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -43,7 +43,7 @@ class TvSearchViewModel @Inject constructor( } viewModelScope.launch { _discoverRows.value = try { - discoverFeedLoader.load(syncManager.isLoggedIn()) + discoverFeedLoader.loadSearch(syncManager.isLoggedIn()) } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { 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 index f060e63e160..177df7d111f 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -73,7 +73,7 @@ class TvSearchViewModelTest { @Test fun `exposes the loaded discover rows`() = runTest { - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( discover(row(id = "trending", title = "Trending", source = "https://lists/trending.json")), ) whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) @@ -88,7 +88,7 @@ class TvSearchViewModelTest { @Test fun `discover rows are empty when loading fails`() = runTest { - whenever(listRepository.getDiscoverFeed()).thenThrow(RuntimeException("Network error")) + whenever(listRepository.getSearchDiscoverFeed()).thenThrow(RuntimeException("Network error")) val viewModel = createViewModel() From 1fed7aa48c35f038c543db9b513e9c85d2cd7688 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 19:11:20 +0200 Subject: [PATCH 21/28] Drive TV search browse categories from the search feed row --- .../discover/TvDiscoverFeedLoader.kt | 22 +++++++++++- .../pocketcasts/discover/TvDiscoverModels.kt | 6 ++++ .../pocketcasts/search/TvSearchViewModel.kt | 24 +++---------- .../search/TvSearchViewModelTest.kt | 35 +++++++++++++++---- 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 8e1c152a131..bfe6fbaadd4 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -4,6 +4,7 @@ import android.content.Context import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository import au.com.shiftyjelly.pocketcasts.servers.model.Discover +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow @@ -12,9 +13,11 @@ import au.com.shiftyjelly.pocketcasts.servers.model.ListType import au.com.shiftyjelly.pocketcasts.servers.model.transformWithRegion import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import timber.log.Timber class TvDiscoverFeedLoader @Inject constructor( private val listRepository: ListRepository, @@ -23,7 +26,24 @@ class TvDiscoverFeedLoader @Inject constructor( ) { suspend fun load(isLoggedIn: Boolean): List = buildRows(listRepository.getDiscoverFeed(), isLoggedIn) - suspend fun loadSearch(isLoggedIn: Boolean): List = buildRows(listRepository.getSearchDiscoverFeed(), isLoggedIn) + suspend fun loadSearch(isLoggedIn: Boolean): TvSearchDiscover = coroutineScope { + val discover = listRepository.getSearchDiscoverFeed() + val rowsDeferred = async { buildRows(discover, isLoggedIn) } + val categoriesDeferred = async { loadCategories(discover) } + TvSearchDiscover(categories = categoriesDeferred.await(), rows = rowsDeferred.await()) + } + + private suspend fun loadCategories(discover: Discover): List { + val source = discover.layout.firstOrNull { it.type is ListType.Categories }?.source ?: return emptyList() + return try { + listRepository.getCategoriesList(source) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search categories") + emptyList() + } + } private suspend fun buildRows(discover: Discover, isLoggedIn: Boolean): List = coroutineScope { val region = discover.regions[settings.discoverCountryCode.value] diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt index e8ea58e46ab..79c978739ec 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt @@ -1,6 +1,12 @@ package au.com.shiftyjelly.pocketcasts.discover import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory + +data class TvSearchDiscover( + val categories: List, + val rows: List, +) sealed interface TvDiscoverRow { val id: String 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 index a376888b23c..750cc762d62 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow -import au.com.shiftyjelly.pocketcasts.preferences.Settings -import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import dagger.hilt.android.lifecycle.HiltViewModel @@ -19,7 +17,6 @@ import timber.log.Timber @HiltViewModel class TvSearchViewModel @Inject constructor( - private val listRepository: ListRepository, private val discoverFeedLoader: TvDiscoverFeedLoader, private val syncManager: SyncManager, ) : ViewModel() { @@ -32,28 +29,15 @@ class TvSearchViewModel @Inject constructor( 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() - } - } - viewModelScope.launch { - _discoverRows.value = try { - discoverFeedLoader.loadSearch(syncManager.isLoggedIn()) + try { + val discover = discoverFeedLoader.loadSearch(syncManager.isLoggedIn()) + _categories.value = discover.categories + _discoverRows.value = discover.rows } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { Timber.e(exception, "Failed to load TV search discover feed") - 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 index 177df7d111f..6c34383a718 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -52,10 +52,12 @@ class TvSearchViewModelTest { } @Test - fun `exposes the loaded browse categories`() = runTest { - whenever(listRepository.getCategoriesList(any())).thenReturn( - listOf(category(1, "Comedy"), category(2, "True Crime")), + fun `exposes the browse categories from the search feed row`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover(categoriesRow(source = "https://categories.json")), ) + whenever(listRepository.getCategoriesList(eq("https://categories.json"))) + .thenReturn(listOf(category(1, "Comedy"), category(2, "True Crime"))) val viewModel = createViewModel() @@ -63,7 +65,21 @@ class TvSearchViewModelTest { } @Test - fun `categories are empty when loading fails`() = runTest { + fun `categories are empty when the feed has no categories row`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover(row(id = "trending", title = "Trending", source = "https://lists/trending.json")), + ) + + val viewModel = createViewModel() + + assertTrue(viewModel.categories.value.isEmpty()) + } + + @Test + fun `categories are empty when the categories request fails`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover(categoriesRow(source = "https://categories.json")), + ) whenever(listRepository.getCategoriesList(any())).thenThrow(RuntimeException("Network error")) val viewModel = createViewModel() @@ -96,7 +112,6 @@ class TvSearchViewModelTest { } private fun createViewModel() = TvSearchViewModel( - listRepository = listRepository, discoverFeedLoader = TvDiscoverFeedLoader( listRepository = listRepository, settings = settings, @@ -119,9 +134,10 @@ class TvSearchViewModelTest { id: String, title: String, source: String, + type: ListType = ListType.PodcastList, ) = DiscoverRow( id = id, - type = ListType.PodcastList, + type = type, displayStyle = DisplayStyle.SmallList(), expandedStyle = ExpandedStyle.PlainList(), expandedTopItemLabel = null, @@ -137,6 +153,13 @@ class TvSearchViewModelTest { sponsoredCategoryIds = null, ) + private fun categoriesRow(source: String) = row( + id = "categories", + title = "Browse By Category", + source = source, + type = ListType.Categories, + ) + private fun podcastFeed(vararg podcastUuids: String) = ListFeed( title = null, subtitle = null, From 5f5bba21bbaf5e6764223f6802ca981be2978c0b Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 20:40:21 +0200 Subject: [PATCH 22/28] Address PR comments --- ...ponsoredTile.kt => TvSinglePodcastTile.kt} | 46 ++++++++++--------- .../discover/TvDiscoverFeedLoader.kt | 11 ++++- .../pocketcasts/discover/TvDiscoverRows.kt | 4 +- .../pocketcasts/search/TvSearchScreen.kt | 13 +++++- .../search/TvSearchViewModelTest.kt | 20 ++++++++ 5 files changed, 67 insertions(+), 27 deletions(-) rename tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/{TvSponsoredTile.kt => TvSinglePodcastTile.kt} (80%) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt similarity index 80% rename from tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt rename to tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt index 620636e8b1b..be723bed64c 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSponsoredTile.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt @@ -36,7 +36,7 @@ import coil3.compose.AsyncImage import au.com.shiftyjelly.pocketcasts.localization.R as LR @Composable -fun TvSponsoredTile( +fun TvSinglePodcastTile( artworkUrl: String, title: String, author: String, @@ -80,33 +80,35 @@ fun TvSponsoredTile( .weight(1f) .padding(start = 24.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - if (isSponsored) { - Text( - text = stringResource(LR.string.sponsored), - style = MaterialTheme.tvTypography.body, - color = textPrimary, - ) + if (isSponsored || author.isNotBlank()) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isSponsored) { + Text( + text = stringResource(LR.string.sponsored), + style = MaterialTheme.tvTypography.body, + color = textPrimary, + ) + if (author.isNotBlank()) { + Text( + text = "·", + style = MaterialTheme.tvTypography.body, + color = textSecondary, + ) + } + } if (author.isNotBlank()) { Text( - text = "·", + text = author, style = MaterialTheme.tvTypography.body, color = textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - if (author.isNotBlank()) { - Text( - text = author, - style = MaterialTheme.tvTypography.body, - color = textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) + } Text( text = title, @@ -133,10 +135,10 @@ fun TvSponsoredTile( @Preview(device = Devices.TV_1080p) @Composable -private fun TvSponsoredTilePreview() { +private fun TvSinglePodcastTilePreview() { TvTheme { Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) { - TvSponsoredTile( + TvSinglePodcastTile( artworkUrl = "", title = "The Writer's Voice", author = "iHeartPodcasts and Kaleidoscope", diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index bfe6fbaadd4..16373f885f5 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -28,9 +28,16 @@ class TvDiscoverFeedLoader @Inject constructor( suspend fun loadSearch(isLoggedIn: Boolean): TvSearchDiscover = coroutineScope { val discover = listRepository.getSearchDiscoverFeed() - val rowsDeferred = async { buildRows(discover, isLoggedIn) } val categoriesDeferred = async { loadCategories(discover) } - TvSearchDiscover(categories = categoriesDeferred.await(), rows = rowsDeferred.await()) + val rows = try { + buildRows(discover, isLoggedIn) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search discover rows") + emptyList() + } + TvSearchDiscover(categories = categoriesDeferred.await(), rows = rows) } private suspend fun loadCategories(discover: Discover): List { diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt index 2347b74b537..9d8747ecdf1 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt @@ -10,7 +10,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvFeaturedTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults import au.com.shiftyjelly.pocketcasts.component.TvRow -import au.com.shiftyjelly.pocketcasts.component.TvSponsoredTile +import au.com.shiftyjelly.pocketcasts.component.TvSinglePodcastTile import au.com.shiftyjelly.pocketcasts.component.TvVideoTile fun LazyListScope.tvDiscoverRow( @@ -53,7 +53,7 @@ fun LazyListScope.tvDiscoverRow( focusRequester = focusRequester, modifier = modifier, ) { podcast -> - TvSponsoredTile( + TvSinglePodcastTile( artworkUrl = podcast.artworkUrl, title = podcast.title, author = podcast.author, 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 index 2b634acc6b4..348a3b48021 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -64,6 +66,14 @@ private fun TvSearchContent( onDelete: () -> Unit, modifier: Modifier = Modifier, ) { + val keyboardState = rememberTvSearchKeyboardState() + var hasAutoFocused by remember { mutableStateOf(false) } + LaunchedEffect(keyboardState.isFocused) { + if (keyboardState.isFocused) { + hasAutoFocused = true + } + } + LazyColumn(modifier = modifier.fillMaxSize()) { item { Column(modifier = Modifier.padding(ContentPadding)) { @@ -75,7 +85,8 @@ private fun TvSearchContent( onSpace = onSpace, onDelete = onDelete, onSubmit = {}, - autoFocus = true, + autoFocus = !hasAutoFocused, + state = keyboardState, ) Spacer(modifier = Modifier.height(24.dp)) } 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 index 6c34383a718..dc6a561f699 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -111,6 +111,26 @@ class TvSearchViewModelTest { assertTrue(viewModel.discoverRows.value.isEmpty()) } + @Test + fun `categories still load when building the discover rows fails`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + Discover( + layout = listOf(categoriesRow(source = "https://categories.json")), + regions = emptyMap(), + regionCodeToken = "[regionCode]", + regionNameToken = "[regionName]", + defaultRegionCode = "us", + ), + ) + whenever(listRepository.getCategoriesList(eq("https://categories.json"))) + .thenReturn(listOf(category(1, "Comedy"))) + + val viewModel = createViewModel() + + assertEquals(listOf("Comedy"), viewModel.categories.value.map { it.name }) + assertTrue(viewModel.discoverRows.value.isEmpty()) + } + private fun createViewModel() = TvSearchViewModel( discoverFeedLoader = TvDiscoverFeedLoader( listRepository = listRepository, From add79fb25d3c9d84a1c00362b5c1e2653b1ef1c4 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Wed, 12 Aug 2026 09:35:57 +0200 Subject: [PATCH 23/28] Address PR comments --- .../component/TvSinglePodcastTile.kt | 17 ++++++++ .../discover/TvDiscoverFeedLoader.kt | 19 ++------- .../pocketcasts/discover/TvDiscoverModels.kt | 6 --- .../pocketcasts/search/TvSearchScreen.kt | 2 +- .../pocketcasts/search/TvSearchViewModel.kt | 20 +++++++-- .../search/TvSearchViewModelTest.kt | 42 ++++++++++++++++++- 6 files changed, 78 insertions(+), 28 deletions(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt index be723bed64c..b6deeef54c0 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt @@ -149,3 +149,20 @@ private fun TvSinglePodcastTilePreview() { } } } + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSinglePodcastTileRecommendedPreview() { + TvTheme { + Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) { + TvSinglePodcastTile( + artworkUrl = "", + title = "The Writer's Voice", + author = "", + description = "New fiction from the pages of The New Yorker, read by its authors.", + isSponsored = false, + onClick = {}, + ) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 16373f885f5..8051fd71d20 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -26,21 +26,9 @@ class TvDiscoverFeedLoader @Inject constructor( ) { suspend fun load(isLoggedIn: Boolean): List = buildRows(listRepository.getDiscoverFeed(), isLoggedIn) - suspend fun loadSearch(isLoggedIn: Boolean): TvSearchDiscover = coroutineScope { - val discover = listRepository.getSearchDiscoverFeed() - val categoriesDeferred = async { loadCategories(discover) } - val rows = try { - buildRows(discover, isLoggedIn) - } catch (exception: CancellationException) { - throw exception - } catch (exception: Exception) { - Timber.e(exception, "Failed to load TV search discover rows") - emptyList() - } - TvSearchDiscover(categories = categoriesDeferred.await(), rows = rows) - } + suspend fun searchDiscoverFeed(): Discover = listRepository.getSearchDiscoverFeed() - private suspend fun loadCategories(discover: Discover): List { + suspend fun loadCategories(discover: Discover): List { val source = discover.layout.firstOrNull { it.type is ListType.Categories }?.source ?: return emptyList() return try { listRepository.getCategoriesList(source) @@ -52,7 +40,7 @@ class TvDiscoverFeedLoader @Inject constructor( } } - private suspend fun buildRows(discover: Discover, isLoggedIn: Boolean): List = coroutineScope { + suspend fun buildRows(discover: Discover, isLoggedIn: Boolean): List = coroutineScope { val region = discover.regions[settings.discoverCountryCode.value] ?: discover.regions[discover.defaultRegionCode] ?: error("Could not resolve discover region") @@ -68,6 +56,7 @@ class TvDiscoverFeedLoader @Inject constructor( .map { row -> async { loadRow(row) } } .awaitAll() .filterNotNull() + // Dedup after loading so a duplicate id whose feed came back empty falls back to a populated one. .distinctBy(TvDiscoverRow::id) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt index 79c978739ec..e8ea58e46ab 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt @@ -1,12 +1,6 @@ package au.com.shiftyjelly.pocketcasts.discover import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage -import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory - -data class TvSearchDiscover( - val categories: List, - val rows: List, -) sealed interface TvDiscoverRow { val id: String 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 index 348a3b48021..c76a2c80d11 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -117,7 +117,7 @@ private fun TvSearchContent( if (query.isBlank()) { discoverRows.forEach { row -> - item(key = "discover-spacer-${row.id}") { Spacer(modifier = Modifier.height(24.dp)) } + item { Spacer(modifier = Modifier.height(24.dp)) } tvDiscoverRow( row = row, onOpenPodcast = {}, 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 index 750cc762d62..bf3de40f98d 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -29,14 +29,26 @@ class TvSearchViewModel @Inject constructor( init { viewModelScope.launch { - try { - val discover = discoverFeedLoader.loadSearch(syncManager.isLoggedIn()) - _categories.value = discover.categories - _discoverRows.value = discover.rows + val discover = try { + discoverFeedLoader.searchDiscoverFeed() } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { Timber.e(exception, "Failed to load TV search discover feed") + return@launch + } + // Publish categories and rows independently so the categories row (2 requests) does not + // wait for the whole row fan-out (~10-20 requests) to resolve. + launch { _categories.value = discoverFeedLoader.loadCategories(discover) } + launch { + _discoverRows.value = try { + discoverFeedLoader.buildRows(discover, syncManager.isLoggedIn()) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search discover rows") + emptyList() + } } } } 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 index dc6a561f699..b48faba893c 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -102,6 +102,42 @@ class TvSearchViewModelTest { assertTrue(rows.single() is TvDiscoverRow.Podcasts) } + @Test + fun `authenticated rows are dropped when logged out`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover( + row(id = "public", title = "Public", source = "https://lists/public.json"), + row(id = "members", title = "Members", source = "https://lists/members.json", authenticated = true), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/public.json"), any())) + .thenReturn(podcastFeed("podcast-public")) + + val viewModel = createViewModel() + + assertEquals(listOf("public"), viewModel.discoverRows.value.map { it.id }) + } + + @Test + fun `single podcast display style maps to a single podcast row`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover( + row( + id = "spotlight", + title = "Spotlight", + source = "https://lists/spotlight.json", + displayStyle = DisplayStyle.SinglePodcast(), + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/spotlight.json"), any())) + .thenReturn(podcastFeed("podcast-spotlight")) + + val viewModel = createViewModel() + + assertTrue(viewModel.discoverRows.value.single() is TvDiscoverRow.SinglePodcast) + } + @Test fun `discover rows are empty when loading fails`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenThrow(RuntimeException("Network error")) @@ -155,10 +191,12 @@ class TvSearchViewModelTest { title: String, source: String, type: ListType = ListType.PodcastList, + displayStyle: DisplayStyle = DisplayStyle.SmallList(), + authenticated: Boolean = false, ) = DiscoverRow( id = id, type = type, - displayStyle = DisplayStyle.SmallList(), + displayStyle = displayStyle, expandedStyle = ExpandedStyle.PlainList(), expandedTopItemLabel = null, title = title, @@ -168,7 +206,7 @@ class TvSearchViewModelTest { regions = listOf("us"), curated = false, sponsored = false, - authenticated = false, + authenticated = authenticated, mostPopularCategoriesId = null, sponsoredCategoryIds = null, ) From 4e37c68d688b4334e4ead008f7db05e23047a55f Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 19:22:21 +0200 Subject: [PATCH 24/28] Default the discover row source and expanded style when absent --- .../shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt index 7a81acf21c6..78a68a61d64 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt @@ -60,10 +60,10 @@ data class DiscoverRow( @Json(name = "id") val id: String?, @Json(name = "type") override val type: ListType, @Json(name = "summary_style") override val displayStyle: DisplayStyle, - @Json(name = "expanded_style") override val expandedStyle: ExpandedStyle, + @Json(name = "expanded_style") override val expandedStyle: ExpandedStyle = ExpandedStyle.PlainList(), @Json(name = "expanded_top_item_label") override val expandedTopItemLabel: String?, @Json(name = "title") override val title: String, - @Json(name = "source") override val source: String, + @Json(name = "source") override val source: String = "", @Json(name = "uuid") override val listUuid: String?, @Json(name = "category_id") val categoryId: Int?, @Json(name = "regions") val regions: List, From ba49445ae981e4ecab77a1ceded97e69d3c1415e Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 19:22:21 +0200 Subject: [PATCH 25/28] Load auth-specific discover feeds on TV home --- .../repositories/lists/ListRepository.kt | 8 +++++ .../servers/server/ListWebService.kt | 6 ++++ .../discover/TvDiscoverFeedLoader.kt | 6 +++- .../pocketcasts/home/TvHomeViewModelTest.kt | 30 +++++++++---------- 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt index 1617a96aaf4..305ae7a8e81 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt @@ -22,6 +22,14 @@ class ListRepository( return listWebService.getSearchDiscoverFeed(platform = platform, version = 3) } + suspend fun getLoggedInDiscoverFeed(): Discover { + return listWebService.getLoggedInDiscoverFeed(platform = platform, version = 3) + } + + suspend fun getLoggedOutDiscoverFeed(): Discover { + return listWebService.getLoggedOutDiscoverFeed(platform = platform, version = 3) + } + suspend fun getListFeed(url: String, authenticated: Boolean? = false): ListFeed? { return runCatching { if (authenticated == true) { diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt index 63786c83423..a788d5e4e5d 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt @@ -15,6 +15,12 @@ interface ListWebService { @GET("/discover/{platform}/content_v{version}_search.json") suspend fun getSearchDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover + @GET("/discover/{platform}/content_v{version}_logged_in.json") + suspend fun getLoggedInDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover + + @GET("/discover/{platform}/content_v{version}_logged_out.json") + suspend fun getLoggedOutDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover + @GET suspend fun getListFeed(@Url url: String): ListFeed diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 8051fd71d20..0eb1543824f 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -24,7 +24,10 @@ class TvDiscoverFeedLoader @Inject constructor( private val settings: Settings, @ApplicationContext private val context: Context, ) { - suspend fun load(isLoggedIn: Boolean): List = buildRows(listRepository.getDiscoverFeed(), isLoggedIn) + suspend fun load(isLoggedIn: Boolean): List { + val feed = if (isLoggedIn) listRepository.getLoggedInDiscoverFeed() else listRepository.getLoggedOutDiscoverFeed() + return buildRows(feed, isLoggedIn) + } suspend fun searchDiscoverFeed(): Discover = listRepository.getSearchDiscoverFeed() @@ -61,6 +64,7 @@ class TvDiscoverFeedLoader @Inject constructor( } private suspend fun loadRow(row: DiscoverRow): TvDiscoverRow? { + if (row.source.isBlank()) return null return when (row.type) { is ListType.PodcastList -> loadPodcastsRow(row) is ListType.EpisodeList -> loadEpisodesRow(row) diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt index 4fef3459e4a..dff39af8c37 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt @@ -92,7 +92,7 @@ class TvHomeViewModelTest { @Test fun `all rows load in feed order`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row( id = "featured", @@ -156,7 +156,7 @@ class TvHomeViewModelTest { @Test fun `authenticated rows are excluded when signed out`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row( id = "recommendations_user", @@ -182,7 +182,7 @@ class TvHomeViewModelTest { @Test fun `authenticated rows load when signed in`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(true) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedInDiscoverFeed()).thenReturn( discover( row( id = "recommendations_user", @@ -209,7 +209,7 @@ class TvHomeViewModelTest { @Test fun `sponsored row marks its podcasts as sponsored`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row( id = "sponsored-id", @@ -235,7 +235,7 @@ class TvHomeViewModelTest { @Test fun `rows that fail to load are dropped`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"), row(id = "empty-id", title = "Row Empty", source = "https://lists/empty.json"), @@ -258,7 +258,7 @@ class TvHomeViewModelTest { @Test fun `category sponsor rows are excluded`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row( id = "category-ad", @@ -285,7 +285,7 @@ class TvHomeViewModelTest { @Test fun `rows with duplicate ids are deduplicated`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), row(id = "trending", title = "Row Trending Again", source = "https://lists/trending-2.json"), @@ -307,7 +307,7 @@ class TvHomeViewModelTest { @Test fun `rows not available in the current region are excluded`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"), row( @@ -333,7 +333,7 @@ class TvHomeViewModelTest { @Test fun `list feed title is preferred over row title`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( discover( row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"), row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), @@ -355,7 +355,7 @@ class TvHomeViewModelTest { @Test fun `feed failure shows error state and retry reloads`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()) + whenever(listRepository.getLoggedOutDiscoverFeed()) .thenThrow(RuntimeException("Network error")) .thenReturn(discover(row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"))) whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) @@ -377,7 +377,7 @@ class TvHomeViewModelTest { @Test fun `feed failure keeps local rows on screen`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenThrow(RuntimeException("Network error")) + whenever(listRepository.getLoggedOutDiscoverFeed()).thenThrow(RuntimeException("Network error")) whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn( listOf(episode(uuid = "episode-1", podcastUuid = "podcast-1")), ) @@ -395,7 +395,7 @@ class TvHomeViewModelTest { @Test fun `keep listening row shows first up next episode even when signed out`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn(discover()) + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover()) whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn( listOf( episode(uuid = "episode-1", podcastUuid = "podcast-1"), @@ -421,7 +421,7 @@ class TvHomeViewModelTest { @Test fun `user episodes in up next are skipped`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn(discover()) + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover()) whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn( listOf( UserEpisode(uuid = "user-file", publishedDate = Date()), @@ -443,7 +443,7 @@ class TvHomeViewModelTest { @Test fun `signed in user sees keep listening, up next and new releases rows before discover rows`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(true) - whenever(listRepository.getDiscoverFeed()).thenReturn( + whenever(listRepository.getLoggedInDiscoverFeed()).thenReturn( discover(row(id = "trending", title = "Row Trending", source = "https://lists/trending.json")), ) whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) @@ -489,7 +489,7 @@ class TvHomeViewModelTest { @Test fun `up next and new releases rows are hidden when signed out`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) - whenever(listRepository.getDiscoverFeed()).thenReturn(discover()) + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover()) whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn( listOf( episode(uuid = "episode-1", podcastUuid = "podcast-1"), From c1c7bc8d562bb3b89f792640f7a9016b008e8f55 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 11 Aug 2026 20:43:19 +0200 Subject: [PATCH 26/28] Address PR comments --- .../discover/TvDiscoverFeedLoader.kt | 3 ++- .../pocketcasts/home/TvHomeViewModelTest.kt | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 0eb1543824f..9532c93d1d9 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -32,7 +32,8 @@ class TvDiscoverFeedLoader @Inject constructor( suspend fun searchDiscoverFeed(): Discover = listRepository.getSearchDiscoverFeed() suspend fun loadCategories(discover: Discover): List { - val source = discover.layout.firstOrNull { it.type is ListType.Categories }?.source ?: return emptyList() + val source = discover.layout.firstOrNull { it.type is ListType.Categories } + ?.source?.takeIf(String::isNotBlank) ?: return emptyList() return try { listRepository.getCategoriesList(source) } catch (exception: CancellationException) { diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt index dff39af8c37..df22cf8ccb8 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt @@ -179,6 +179,27 @@ class TvHomeViewModelTest { verify(listRepository, never()).getListFeed(eq("https://lists/user.json"), any()) } + @Test + fun `rows without a source are dropped`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn( + discover( + row(id = "up-next-placeholder", title = "Up Next", source = ""), + row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals(listOf("trending"), state.rows.map { it.id }) + } + verify(listRepository, never()).getListFeed(eq(""), any()) + } + @Test fun `authenticated rows load when signed in`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(true) From 14ede724b4551f30ef3fd6072d8405945dd5cfc5 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Wed, 12 Aug 2026 09:40:50 +0200 Subject: [PATCH 27/28] Address PR comments --- .../servers/model/DiscoverModel.kt | 1 + .../servers/model/DiscoverModelTest.kt | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt index 78a68a61d64..0cdfd095838 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt @@ -63,6 +63,7 @@ data class DiscoverRow( @Json(name = "expanded_style") override val expandedStyle: ExpandedStyle = ExpandedStyle.PlainList(), @Json(name = "expanded_top_item_label") override val expandedTopItemLabel: String?, @Json(name = "title") override val title: String, + // Blank when the feed omits the key: a placeholder row the client fills locally. Consumers must skip blank sources. @Json(name = "source") override val source: String = "", @Json(name = "uuid") override val listUuid: String?, @Json(name = "category_id") val categoryId: Int?, diff --git a/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt b/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt new file mode 100644 index 00000000000..6e4eb9e0f82 --- /dev/null +++ b/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt @@ -0,0 +1,33 @@ +package au.com.shiftyjelly.pocketcasts.servers.model + +import com.squareup.moshi.Moshi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiscoverModelTest { + + private val adapter = Moshi.Builder() + .add(ListTypeMoshiAdapter()) + .add(DisplayStyleMoshiAdapter()) + .add(ExpandedStyleMoshiAdapter()) + .build() + .adapter(DiscoverRow::class.java) + + @Test + fun `discover row defaults source and expanded style when the keys are absent`() { + val row = adapter.fromJson( + """ + { + "type": "podcast_list", + "summary_style": "small_list", + "title": "Trending", + "regions": ["us"] + } + """.trimIndent(), + ) + + assertEquals("", row?.source) + assertTrue(row?.expandedStyle is ExpandedStyle.PlainList) + } +} From c93d5d9c5466fc2bb3fca8d6703ed8374731e3a6 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Wed, 12 Aug 2026 10:22:15 +0200 Subject: [PATCH 28/28] Use the system on-screen keyboard for TV search --- .../pocketcasts/search/TvSearchField.kt | 95 ++++++++++--------- .../pocketcasts/search/TvSearchScreen.kt | 35 ++----- 2 files changed, 59 insertions(+), 71 deletions(-) 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 index be06edc050b..2db2dfd9ea2 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt @@ -1,21 +1,27 @@ package au.com.shiftyjelly.pocketcasts.search import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -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.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +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.SolidColor +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 @@ -31,50 +37,53 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR @Composable internal fun TvSearchField( query: String, + onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, + autoFocus: Boolean = false, ) { - val scrollState = rememberScrollState() - LaunchedEffect(scrollState.maxValue) { scrollState.scrollTo(scrollState.maxValue) } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + if (autoFocus) { + LaunchedEffect(Unit) { + runCatching { focusRequester.requestFocus() } + keyboardController?.show() + } + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - Icon( - painter = painterResource(IR.drawable.ic_search), - contentDescription = null, - tint = MaterialTheme.tvColors.textSecondary, - modifier = Modifier.size(40.dp), - ) - Spacer(modifier = Modifier.width(20.dp)) - if (query.isEmpty()) { - Text( - text = stringResource(LR.string.search), - style = MaterialTheme.tvTypography.title2, - color = MaterialTheme.tvColors.textSecondary, - ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.horizontalScroll(scrollState), - ) { - Text( - text = query, - style = MaterialTheme.tvTypography.title2, - color = MaterialTheme.tvColors.textPrimary, - maxLines = 1, - softWrap = false, - ) - Spacer(modifier = Modifier.width(4.dp)) - Box( - modifier = Modifier - .width(3.dp) - .height(36.dp) - .background(MaterialTheme.tvColors.textPrimary), + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.tvTypography.title2.copy(color = MaterialTheme.tvColors.textPrimary), + cursorBrush = SolidColor(MaterialTheme.tvColors.textPrimary), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Search, + ), + modifier = modifier.focusRequester(focusRequester), + decorationBox = { innerTextField -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(IR.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.tvColors.textSecondary, + modifier = Modifier.size(40.dp), ) + Spacer(modifier = Modifier.width(20.dp)) + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + text = stringResource(LR.string.search), + style = MaterialTheme.tvTypography.title2, + color = MaterialTheme.tvColors.textSecondary, + ) + } + innerTextField() + } } - } - } + }, + ) } @Preview(device = Devices.TV_1080p) @@ -86,7 +95,7 @@ private fun TvSearchFieldPreview() { .background(MaterialTheme.tvColors.backgroundSunken) .padding(48.dp), ) { - TvSearchField(query = "huberman") + TvSearchField(query = "huberman", 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 index c76a2c80d11..c17c660e44f 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -11,10 +11,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -49,9 +47,7 @@ fun TvSearchScreen( query = query, categories = categories, discoverRows = discoverRows, - onCharacter = { query += it }, - onSpace = { query += ' ' }, - onDelete = { query = query.dropLast(1) }, + onQueryChange = { query = it }, modifier = modifier, ) } @@ -61,32 +57,17 @@ private fun TvSearchContent( query: String, categories: List, discoverRows: List, - onCharacter: (Char) -> Unit, - onSpace: () -> Unit, - onDelete: () -> Unit, + onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, ) { - val keyboardState = rememberTvSearchKeyboardState() - var hasAutoFocused by remember { mutableStateOf(false) } - LaunchedEffect(keyboardState.isFocused) { - if (keyboardState.isFocused) { - hasAutoFocused = true - } - } - LazyColumn(modifier = modifier.fillMaxSize()) { item { Column(modifier = Modifier.padding(ContentPadding)) { Spacer(modifier = Modifier.height(40.dp)) - TvSearchField(query = query) - Spacer(modifier = Modifier.height(40.dp)) - TvSearchKeyboard( - onCharacter = onCharacter, - onSpace = onSpace, - onDelete = onDelete, - onSubmit = {}, - autoFocus = !hasAutoFocused, - state = keyboardState, + TvSearchField( + query = query, + onQueryChange = onQueryChange, + autoFocus = true, ) Spacer(modifier = Modifier.height(24.dp)) } @@ -144,9 +125,7 @@ private fun TvSearchScreenPreview() { DiscoverCategory(id = 3, name = "Fiction", icon = "", source = ""), ), discoverRows = emptyList(), - onCharacter = {}, - onSpace = {}, - onDelete = {}, + onQueryChange = {}, ) } }