Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
21ddc23
Add a custom on-screen keyboard component for TV search
sztomek Aug 11, 2026
ab117d3
Address PR comments
sztomek Aug 11, 2026
5f479d3
Address PR comments
sztomek Aug 11, 2026
fc3c625
Activate the highlighted key on Enter instead of submitting
sztomek Aug 11, 2026
1c1dd3d
Address PR comments
sztomek Aug 11, 2026
8529c0d
Add TV search query display field
sztomek Aug 11, 2026
00afaa8
Show the TV search keyboard screen on the Search tab
sztomek Aug 11, 2026
bba5ffe
Add TV category tile component
sztomek Aug 11, 2026
14fb5d9
Load browse categories in a TV search view model
sztomek Aug 11, 2026
8cdb93f
Show the browse categories row below the search keyboard
sztomek Aug 11, 2026
4989771
Refine category tile sizing and resting background
sztomek Aug 11, 2026
67f28cf
Address PR comments
sztomek Aug 11, 2026
ae186ef
Address PR comments
sztomek Aug 12, 2026
fcaf23b
Extract shared TV discover models and feed loader
sztomek Aug 11, 2026
1c2d9cc
Extract shared TV discover row rendering
sztomek Aug 11, 2026
c39e432
Add sponsored single-podcast tile for TV discover
sztomek Aug 11, 2026
f1b8467
Load the discover feed in the TV search view model
sztomek Aug 11, 2026
839db41
Show the discover feed below browse categories on TV search
sztomek Aug 11, 2026
5aa822b
Dedup discover rows by id in the shared loader
sztomek Aug 11, 2026
f582185
Load the dedicated search discover feed on TV search
sztomek Aug 11, 2026
1fed7aa
Drive TV search browse categories from the search feed row
sztomek Aug 11, 2026
5f5bba2
Address PR comments
sztomek Aug 11, 2026
add79fb
Address PR comments
sztomek Aug 12, 2026
4e37c68
Default the discover row source and expanded style when absent
sztomek Aug 11, 2026
ba49445
Load auth-specific discover feeds on TV home
sztomek Aug 11, 2026
c1c7bc8
Address PR comments
sztomek Aug 11, 2026
14ede72
Address PR comments
sztomek Aug 12, 2026
c93d5d9
Use the system on-screen keyboard for TV search
sztomek Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions modules/services/localization/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@
<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_keyboard">On-screen keyboard</string>
<string name="tv_search_browse_categories">Browse categories</string>
<string name="tv_search_key_space">Space</string>
<string name="tv_search_key_symbols" translatable="false">123</string>
<string name="tv_search_key_letters" translatable="false">ABC</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
Expand Up @@ -18,6 +18,18 @@ class ListRepository(
return listWebService.getDiscoverFeed(platform = platform, version = 3)
}

suspend fun getSearchDiscoverFeed(): Discover {
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ 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,
// 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?,
@Json(name = "regions") val regions: List<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ 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("/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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
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

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

@Preview(device = Devices.TV_1080p)
@Composable
private fun TvCategoryTilePreview() {
TvTheme {
Column(
modifier = Modifier
.background(MaterialTheme.tvColors.backgroundSunken)
.padding(48.dp),
) {
TvCategoryTile(
category = DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
onClick = {},
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 TvSinglePodcastTile(
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),
) {
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 = 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 TvSinglePodcastTilePreview() {
TvTheme {
Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) {
TvSinglePodcastTile(
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 = {},
)
}
}
}

@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 = {},
)
}
}
}
Loading