Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@
<string name="tv_row_recommendations">Recommendations</string>
<string name="tv_row_because_you_liked">Because you liked Podcast</string>
<string name="tv_home_keep_listening">Keep Listening</string>
<string name="tv_search_browse_categories">Browse categories</string>
<string name="tv_profile_starred_episodes">Starred Episodes</string>
<string name="tv_sign_in_title">Log in to Pocket Casts</string>
<string name="tv_sign_in_step_scan">Scan the QR code or go to %1$s</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package au.com.shiftyjelly.pocketcasts.component

import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
import au.com.shiftyjelly.pocketcasts.theme.TvTheme
import au.com.shiftyjelly.pocketcasts.theme.tvColors
import au.com.shiftyjelly.pocketcasts.theme.tvTypography
import coil3.compose.AsyncImage

@Composable
fun TvCategoryTile(
category: DiscoverCategory,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary
Comment thread
sztomek marked this conversation as resolved.

TvTile(
onClick = onClick,
colors = CardDefaults.colors(
containerColor = MaterialTheme.tvColors.backgroundOverlay,
focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay,
),
interactionSource = interactionSource,
modifier = modifier
.width(280.dp)
.height(128.dp),
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
AsyncImage(
model = category.icon,
contentDescription = null,
colorFilter = ColorFilter.tint(contentColor),
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = category.name,
style = MaterialTheme.tvTypography.body,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp),
)
Comment thread
sztomek marked this conversation as resolved.
}
}
}

@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 = {},
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,8 +106,8 @@ fun TvScaffold(
onConsumeOpenRequest = { isNowPlayingOpenRequested = false },
)

else -> Box(modifier = belowTopBar) {
TvTabPlaceholder(tab = tab)
is TvTab.Search -> Box(modifier = belowTopBar) {
TvSearchScreen()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package au.com.shiftyjelly.pocketcasts.search

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import au.com.shiftyjelly.pocketcasts.component.TvTile
import au.com.shiftyjelly.pocketcasts.theme.TvTheme
import au.com.shiftyjelly.pocketcasts.theme.tvColors
import au.com.shiftyjelly.pocketcasts.theme.tvTypography
import au.com.shiftyjelly.pocketcasts.images.R as IR
import au.com.shiftyjelly.pocketcasts.localization.R as LR

@Composable
internal fun TvSearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var editing by remember { mutableStateOf(false) }
var restoreRestFocus by remember { mutableStateOf(false) }
val fieldFocusRequester = remember { FocusRequester() }
val restFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current

LaunchedEffect(editing) {
if (editing) {
runCatching { fieldFocusRequester.requestFocus() }
keyboardController?.show()
} else {
keyboardController?.hide()
if (restoreRestFocus) {
runCatching { restFocusRequester.requestFocus() }
restoreRestFocus = false
}
}
}

if (editing) {
var hasFocused by remember { mutableStateOf(false) }

BackHandler(enabled = true) {
editing = false
restoreRestFocus = true
}

BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.tvTypography.title3.copy(color = MaterialTheme.tvColors.textPrimary),
cursorBrush = SolidColor(MaterialTheme.tvColors.textPrimary),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
editing = false
restoreRestFocus = true
},
),
modifier = modifier
.focusRequester(fieldFocusRequester)
.onFocusChanged {
if (it.isFocused) {
hasFocused = true
} else if (hasFocused) {
editing = false
}
}
.onPreviewKeyEvent { event ->
when {
event.type == KeyEventType.KeyDown && event.key == Key.DirectionDown -> {
focusManager.moveFocus(FocusDirection.Down)
true
}

event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp -> {
focusManager.moveFocus(FocusDirection.Up)
true
}

else -> false
}
},
Comment thread
sztomek marked this conversation as resolved.
decorationBox = { innerTextField ->
TvSearchFieldContent(
query = query,
contentColor = MaterialTheme.tvColors.textPrimary,
innerTextField = innerTextField,
)
},
)
} else {
TvTile(
onClick = { editing = true },
scale = CardDefaults.scale(focusedScale = 1f),
shape = CardDefaults.shape(shape = RectangleShape),
colors = CardDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.tvColors.textSecondary,
focusedContainerColor = Color.Transparent,
focusedContentColor = MaterialTheme.tvColors.textPrimary,
pressedContainerColor = Color.Transparent,
),
modifier = modifier.focusRequester(restFocusRequester),
) {
TvSearchFieldContent(query = query, contentColor = LocalContentColor.current)
}
}
}

@Composable
private fun TvSearchFieldContent(
query: String,
contentColor: Color,
modifier: Modifier = Modifier,
innerTextField: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(IR.drawable.ic_search),
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(32.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
if (innerTextField != null) {
innerTextField()
} else if (query.isNotEmpty()) {
Text(
text = query,
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textPrimary,
)
}
Comment thread
sztomek marked this conversation as resolved.
}
Comment on lines +175 to +192

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The typed query disappears as soon as you leave editing mode.

In the resting branch the field is rendered with innerTextField = null (L106). This Box then contains the placeholder only when query.isEmpty(), and nothing else — so with a non-empty query it renders an empty Box. Type huberman, press Enter (ImeAction.SearchstopEditing()) or Back, and the field collapses to just the magnifying glass. The state is still there (query is hoisted in TvSearchScreen), it's simply never drawn.

The preview is the giveaway: TvSearchFieldPreview passes query = "huberman" and editing starts false, so the preview renders a blank field rather than the query it's clearly meant to show.

Suggested change
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
innerTextField?.invoke()
}
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
text = stringResource(LR.string.search),
style = MaterialTheme.tvTypography.title3,
color = MaterialTheme.tvColors.textSecondary,
)
}
if (innerTextField != null) {
innerTextField()
} else {
Text(
text = query,
style = MaterialTheme.tvTypography.title3,
color = contentColor,
maxLines = 1,
)
}
}

That also gives contentColor a second use — right now it's computed from focus state in both branches but only ever applied to the icon, so the grey→white transition @geekygecko asked for lands on the icon alone and not on the text beside it.

}
}

@Preview(device = Devices.TV_1080p)
@Composable
private fun TvSearchFieldPreview() {
TvTheme {
Box(
modifier = Modifier
.background(MaterialTheme.tvColors.backgroundSunken)
.padding(48.dp),
) {
TvSearchField(query = "", onQueryChange = {})
Comment thread
sztomek marked this conversation as resolved.
}
}
}
Loading