From 36c92e3a2548254fc8687612dbbf0ea5c382fcdc Mon Sep 17 00:00:00 2001 From: Demos Backos Date: Sat, 10 Oct 2026 09:46:40 -0400 Subject: [PATCH 1/4] Add Latest tab: combined newest-first episode list across all followed podcasts - New Latest screen (default tab) merges episodes from every followed podcast, sorted newest to oldest (capped at 200) - Built during the existing background refresh, so no extra network requests - Updates immediately on follow/unfollow; refresh button in the top bar - Play, download, progress and info work the same as on the podcast page - Autoplay-next from this list moves to the next newer episode in the list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUy8jB6SjMeBHPV1KQZ8co --- .../java/com/calmcast/podcast/MainActivity.kt | 60 ++++++- .../calmcast/podcast/ui/PodcastViewModel.kt | 101 +++++++++++- .../podcast/ui/latest/LatestEpisodesScreen.kt | 153 ++++++++++++++++++ 3 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/calmcast/podcast/ui/latest/LatestEpisodesScreen.kt diff --git a/app/src/main/java/com/calmcast/podcast/MainActivity.kt b/app/src/main/java/com/calmcast/podcast/MainActivity.kt index e3f88cb..922b9d3 100644 --- a/app/src/main/java/com/calmcast/podcast/MainActivity.kt +++ b/app/src/main/java/com/calmcast/podcast/MainActivity.kt @@ -21,6 +21,8 @@ import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.sharp.Clear @@ -71,6 +73,7 @@ import com.calmcast.podcast.ui.PodcastViewModel import com.calmcast.podcast.ui.PodcastViewModelFactory import com.calmcast.podcast.ui.SettingsScreen import com.calmcast.podcast.ui.downloads.DownloadsScreen +import com.calmcast.podcast.ui.latest.LatestEpisodesScreen import com.calmcast.podcast.ui.podcastdetail.FeedNotFoundScreen import com.calmcast.podcast.ui.podcastdetail.PodcastDetailScreen import com.calmcast.podcast.ui.search.SearchScreen @@ -90,12 +93,14 @@ import com.mudita.mmd.components.top_app_bar.TopAppBarMMD import kotlinx.coroutines.launch sealed class Screen(val route: String, val label: String, val icon: ImageVector) { + object Latest : Screen("latest", "Latest", Icons.Outlined.NewReleases) object Subscriptions : Screen("subscriptions", "Following", Icons.Outlined.StarBorder) object Downloads : Screen("downloads", "Downloads", Icons.Outlined.Download) object Settings : Screen("settings", "Settings", Icons.Outlined.Settings) } val navItems = listOf( + Screen.Latest, Screen.Subscriptions, Screen.Downloads, Screen.Settings @@ -377,6 +382,22 @@ fun CalmCastApp(pipStateHolder: androidx.compose.runtime.MutableState, } } + if (currentDestination?.route == "latest") { + ButtonMMD( + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + containerColor = MaterialTheme.colorScheme.background + ), + onClick = { viewModel.refreshLatestEpisodes() } + ) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = "Refresh latest episodes" + ) + } + } + if (currentDestination?.route == "subscriptions") { ButtonMMD( contentPadding = PaddingValues(0.dp), @@ -475,13 +496,49 @@ fun CalmCastApp(pipStateHolder: androidx.compose.runtime.MutableState, ) { paddingValues -> NavHost( navController = navController, - startDestination = "subscriptions", + startDestination = "latest", modifier = Modifier.padding(paddingValues), enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, popEnterTransition = { EnterTransition.None }, popExitTransition = { ExitTransition.None } ) { + composable( + "latest", + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None } + ) { + LatestEpisodesScreen( + episodes = viewModel.latestEpisodes.value, + downloads = viewModel.downloads.value, + playbackPositions = viewModel.playbackPositions.value, + hasSubscriptions = viewModel.subscriptions.value.isNotEmpty(), + isLoading = viewModel.isLoading.value || viewModel.isFetchingLatestEpisodes.value, + currentPlayingEpisodeId = viewModel.currentEpisode.value?.id, + isBuffering = viewModel.isBuffering.value, + onEpisodeClick = { episode: Episode -> + viewModel.playEpisodeFromLatest(episode) + }, + onDownloadClick = { episode: Episode -> + viewModel.downloadEpisode(episode) + }, + onDeleteClick = { episode: Episode -> + viewModel.deleteEpisode(episode) + }, + onPauseClick = { episode: Episode -> + viewModel.pauseDownload(episode.id) + }, + onCancelClick = { episode: Episode -> + viewModel.cancelDownload(episode.id) + }, + onResumeClick = { episode: Episode -> + viewModel.resumeDownload(episode.id) + }, + removeDividers = viewModel.removeHorizontalDividers.value + ) + } composable( "subscriptions", enterTransition = { EnterTransition.None }, @@ -723,6 +780,7 @@ fun CalmCastApp(pipStateHolder: androidx.compose.runtime.MutableState, @Composable fun getAppBarTitle(currentRoute: String?, viewModel: PodcastViewModel): String { return when { + currentRoute == "latest" -> "Latest" currentRoute == "subscriptions" -> "Following" currentRoute == "search" -> "Search" currentRoute == "downloads" -> "Downloads" diff --git a/app/src/main/java/com/calmcast/podcast/ui/PodcastViewModel.kt b/app/src/main/java/com/calmcast/podcast/ui/PodcastViewModel.kt index 6fed808..2e96fb9 100644 --- a/app/src/main/java/com/calmcast/podcast/ui/PodcastViewModel.kt +++ b/app/src/main/java/com/calmcast/podcast/ui/PodcastViewModel.kt @@ -72,6 +72,8 @@ class PodcastViewModel( companion object { private const val TAG = "PodcastViewModel" private const val SEARCH_DEBOUNCE_MS = 500L + // Cap on the combined Latest list so huge back catalogs don't bloat it + private const val MAX_LATEST_EPISODES = 200 } private val subscriptionManager = SubscriptionManager(application, podcastDao) @@ -198,6 +200,13 @@ class PodcastViewModel( private val _isFetchingLatestEpisodes = mutableStateOf(false) val isFetchingLatestEpisodes: State = _isFetchingLatestEpisodes + // Combined feed of episodes from every followed podcast, newest first + private val _latestEpisodes = mutableStateOf>(emptyList()) + val latestEpisodes: State> = _latestEpisodes + + // Episodes per followed podcast, used to rebuild the combined feed + private val latestEpisodesByPodcast = java.util.concurrent.ConcurrentHashMap>() + private var searchJob: Job? = null private var sleepTimerJob: Job? = null private var lastSaveTime: Long = 0L @@ -205,7 +214,8 @@ class PodcastViewModel( private enum class AutoplayContext { PODCAST, - DOWNLOADS + DOWNLOADS, + LATEST } private var lastAutoplayContext: AutoplayContext = AutoplayContext.PODCAST @@ -353,8 +363,8 @@ class PodcastViewModel( _currentArtworkUri.value = artworkUrl.toUri() } - if (contextType == AutoplayContext.PODCAST.name) { - lastAutoplayContext = AutoplayContext.PODCAST + if (contextType == AutoplayContext.PODCAST.name || contextType == AutoplayContext.LATEST.name) { + lastAutoplayContext = AutoplayContext.valueOf(contextType) launch { val details = repository.getPodcastDetails(episode.podcastId).first().getOrNull() if (details != null) { @@ -435,6 +445,16 @@ class PodcastViewModel( handled = true } } + AutoplayContext.LATEST -> { + val episodes = _latestEpisodes.value + val currentId = _currentEpisode.value?.id + val currentIndex = episodes.indexOfFirst { it.id == currentId } + val nextEpisode = if (currentIndex > 0) episodes.getOrNull(currentIndex - 1) else null + if (nextEpisode != null) { + playEpisode(nextEpisode, restart = true, context = AutoplayContext.LATEST) + handled = true + } + } } if (handled) { return @@ -480,6 +500,12 @@ class PodcastViewModel( return } + val fromLatest = _latestEpisodes.value.find { it.id == mediaId } + if (fromLatest != null) { + _currentEpisode.value = fromLatest + return + } + val extras = mediaItem.mediaMetadata.extras if (extras != null && extras.containsKey("episodeId")) { val reconstructedEpisode = Episode( @@ -695,6 +721,7 @@ class PodcastViewModel( val updatedList = (_subscriptions.value + podcast).sortedBy { it.title } _subscriptions.value = updatedList } + addPodcastToLatestEpisodes(podcast.id) }.onFailure { _errorMessage.value = it.message } } } @@ -703,6 +730,8 @@ class PodcastViewModel( viewModelScope.launch { repository.unsubscribeFromPodcast(podcastId).onSuccess { _subscriptions.value = _subscriptions.value.filter { it.id != podcastId } + latestEpisodesByPodcast.remove(podcastId) + rebuildLatestEpisodes() }.onFailure { error -> Log.e(TAG, "unsubscribeFromPodcast failed for $podcastId", error) _errorMessage.value = error.message @@ -721,6 +750,7 @@ class PodcastViewModel( val updatedList = (_subscriptions.value + podcast).sortedBy { it.title } _subscriptions.value = updatedList } + addPodcastToLatestEpisodes(podcast.id) }.onFailure { _errorMessage.value = it.message } @@ -842,6 +872,56 @@ class PodcastViewModel( loadInitialData() } + /** + * Re-checks every followed podcast's feed and rebuilds the Latest list. + * Called from the refresh button on the Latest screen. + */ + fun refreshLatestEpisodes() { + if (_isFetchingLatestEpisodes.value) return + loadInitialData() + } + + /** + * Merge the per-podcast episode lists into one list sorted newest to oldest. + */ + private fun rebuildLatestEpisodes() { + val merged = latestEpisodesByPodcast.values + .flatten() + .distinctBy { it.id } + .sortedByDescending { it.publishDateMillis } + .take(MAX_LATEST_EPISODES) + _latestEpisodes.value = merged + + viewModelScope.launch { + try { + val ids = merged.map { it.id } + if (ids.isNotEmpty()) { + val positions = playbackPositionDao.getPlaybackPositions(ids) + _playbackPositions.value = _playbackPositions.value + positions.associateBy { it.episodeId } + } + } catch (e: Exception) { + Log.e(TAG, "Error loading playback positions for latest episodes", e) + } + } + } + + /** + * Fetch one podcast's episodes (e.g. right after following it) and merge them into the Latest list. + */ + private fun addPodcastToLatestEpisodes(podcastId: String) { + viewModelScope.launch { + try { + val episodes = repository.getPodcastDetails(podcastId).first().getOrNull()?.episodes + if (episodes != null && isSubscribed(podcastId)) { + latestEpisodesByPodcast[podcastId] = episodes + rebuildLatestEpisodes() + } + } catch (e: Exception) { + Log.e(TAG, "Error adding $podcastId to latest episodes", e) + } + } + } + private suspend fun refreshSubscribedPodcastEpisodes(): List { return try { // Invalidate cache for all podcasts (app init/foreground) @@ -855,6 +935,10 @@ class PodcastViewModel( try { val episodes = repository.getPodcastDetails(podcast.id).first().getOrNull()?.episodes + if (episodes != null) { + latestEpisodesByPodcast[podcast.id] = episodes + } + episodes?.let { var index = 0 val lastSeenEpisode = it.find { ep -> ep.id == podcast.lastSeenEpisodeId } @@ -896,6 +980,13 @@ class PodcastViewModel( } // After updating newEpisodeCount in database, refetch podcasts to get fresh data subscribedPodcasts = repository.getSubscribedPodcasts().first().getOrNull() ?: emptyList() + + // Drop podcasts that are no longer followed, then rebuild the combined feed. + // Podcasts whose feed failed to load this time keep their previous episodes. + val followedIds = subscribedPodcasts.map { it.id }.toSet() + latestEpisodesByPodcast.keys.retainAll(followedIds) + rebuildLatestEpisodes() + subscribedPodcasts } catch (e: Exception) { Log.e(TAG, "Error refreshing all podcast episodes", e) @@ -911,6 +1002,10 @@ class PodcastViewModel( playEpisode(episode, restart, AutoplayContext.DOWNLOADS) } + fun playEpisodeFromLatest(episode: Episode, restart: Boolean = false) { + playEpisode(episode, restart, AutoplayContext.LATEST) + } + private fun playEpisode( episode: Episode, restart: Boolean = false, diff --git a/app/src/main/java/com/calmcast/podcast/ui/latest/LatestEpisodesScreen.kt b/app/src/main/java/com/calmcast/podcast/ui/latest/LatestEpisodesScreen.kt new file mode 100644 index 0000000..7a18234 --- /dev/null +++ b/app/src/main/java/com/calmcast/podcast/ui/latest/LatestEpisodesScreen.kt @@ -0,0 +1,153 @@ +package com.calmcast.podcast.ui.latest + +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.calmcast.podcast.data.PlaybackPosition +import com.calmcast.podcast.data.PodcastRepository.Episode +import com.calmcast.podcast.data.download.Download +import com.calmcast.podcast.ui.common.SafeHtmlText +import com.calmcast.podcast.ui.podcastdetail.EpisodeItem +import com.mudita.mmd.components.bottom_sheet.ModalBottomSheetMMD +import com.mudita.mmd.components.lazy.LazyColumnMMD +import com.mudita.mmd.components.progress_indicator.CircularProgressIndicatorMMD + +/** + * One combined list of episodes from every followed podcast, newest first. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LatestEpisodesScreen( + episodes: List, + downloads: List, + playbackPositions: Map, + hasSubscriptions: Boolean, + isLoading: Boolean, + currentPlayingEpisodeId: String? = null, + isBuffering: Boolean = false, + onEpisodeClick: (Episode) -> Unit, + onDownloadClick: (Episode) -> Unit, + onDeleteClick: (Episode) -> Unit, + onPauseClick: (Episode) -> Unit = {}, + onCancelClick: (Episode) -> Unit = {}, + onResumeClick: (Episode) -> Unit = {}, + removeDividers: Boolean = false +) { + var selectedEpisodeForInfo by remember { mutableStateOf(null) } + + if (selectedEpisodeForInfo != null) { + ModalBottomSheetMMD(onDismissRequest = { selectedEpisodeForInfo = null }) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp) + ) { + Text( + text = selectedEpisodeForInfo?.title?.takeIf { it.isNotBlank() } ?: "Episode", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = Color.Black, + modifier = Modifier.padding(bottom = 16.dp, top = 16.dp) + ) + if (!selectedEpisodeForInfo?.description.isNullOrEmpty()) { + SafeHtmlText( + html = selectedEpisodeForInfo?.description ?: "", + forceBlackText = true + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + val downloadsByEpisodeId = remember(downloads) { downloads.associateBy { it.episode.id } } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 16.dp, end = 0.dp, bottom = 16.dp) + ) { + when { + episodes.isEmpty() && isLoading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicatorMMD() + } + } + + episodes.isEmpty() -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(end = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (hasSubscriptions) { + "No episodes found. Check your connection and tap refresh." + } else { + "Follow some podcasts and their newest episodes will show up here." + }, + textAlign = TextAlign.Center + ) + } + } + + else -> { + LazyColumnMMD(modifier = Modifier.weight(1f)) { + items(episodes.size, key = { index -> episodes[index].id }) { index -> + val episode = episodes[index] + val isCurrentlyPlaying = episode.id == currentPlayingEpisodeId + + EpisodeItem( + episode = episode, + download = downloadsByEpisodeId[episode.id], + isLastItem = index == episodes.lastIndex, + playbackPosition = playbackPositions[episode.id], + isCurrentlyPlaying = isCurrentlyPlaying, + isBuffering = isBuffering && isCurrentlyPlaying, + showPodcastName = true, + onClick = { onEpisodeClick(episode) }, + onDownloadClick = { onDownloadClick(episode) }, + onDeleteClick = { onDeleteClick(episode) }, + onPauseClick = { onPauseClick(episode) }, + onCancelClick = { onCancelClick(episode) }, + onResumeClick = { onResumeClick(episode) }, + onInfoClick = { selectedEpisodeForInfo = episode }, + removeDividers = removeDividers, + customPaddingValues = PaddingValues( + start = 0.dp, + end = 0.dp, + top = 16.dp, + bottom = 0.dp + ) + ) + } + } + } + } + } +} -- 2.43.0 From 54afcfbfd6f76d860209416d9139ebd5c6389110 Mon Sep 17 00:00:00 2001 From: Demos Backos Date: Sat, 10 Oct 2026 11:00:05 -0400 Subject: [PATCH 2/4] Add Search to the bottom navigation bar Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUy8jB6SjMeBHPV1KQZ8co --- app/src/main/java/com/calmcast/podcast/MainActivity.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/calmcast/podcast/MainActivity.kt b/app/src/main/java/com/calmcast/podcast/MainActivity.kt index 922b9d3..946fc27 100644 --- a/app/src/main/java/com/calmcast/podcast/MainActivity.kt +++ b/app/src/main/java/com/calmcast/podcast/MainActivity.kt @@ -95,6 +95,7 @@ import kotlinx.coroutines.launch sealed class Screen(val route: String, val label: String, val icon: ImageVector) { object Latest : Screen("latest", "Latest", Icons.Outlined.NewReleases) object Subscriptions : Screen("subscriptions", "Following", Icons.Outlined.StarBorder) + object Search : Screen("search", "Search", Icons.Outlined.Search) object Downloads : Screen("downloads", "Downloads", Icons.Outlined.Download) object Settings : Screen("settings", "Settings", Icons.Outlined.Settings) } @@ -102,6 +103,7 @@ sealed class Screen(val route: String, val label: String, val icon: ImageVector) val navItems = listOf( Screen.Latest, Screen.Subscriptions, + Screen.Search, Screen.Downloads, Screen.Settings ) -- 2.43.0 From 802dddcc8ba4ee4ea4ca7c2e9b554b7b95910951 Mon Sep 17 00:00:00 2001 From: Demos Backos Date: Sat, 10 Oct 2026 11:33:11 -0400 Subject: [PATCH 3/4] Keep bottom bar labels on one line Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUy8jB6SjMeBHPV1KQZ8co --- app/src/main/java/com/calmcast/podcast/MainActivity.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/calmcast/podcast/MainActivity.kt b/app/src/main/java/com/calmcast/podcast/MainActivity.kt index 946fc27..04286d9 100644 --- a/app/src/main/java/com/calmcast/podcast/MainActivity.kt +++ b/app/src/main/java/com/calmcast/podcast/MainActivity.kt @@ -480,7 +480,11 @@ fun CalmCastApp(pipStateHolder: androidx.compose.runtime.MutableState, label = { Text( screen.label, - fontWeight = if (isSelected) FontWeight.Black else FontWeight.Medium + fontWeight = if (isSelected) FontWeight.Black else FontWeight.Medium, + fontSize = 12.sp, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Visible ) }, selected = isSelected, -- 2.43.0 From 1974df2d5b7384874705402de175e290d85dbabc Mon Sep 17 00:00:00 2001 From: Demos Backos Date: Sat, 10 Oct 2026 13:34:23 -0400 Subject: [PATCH 4/4] Add episode search within a podcast Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUy8jB6SjMeBHPV1KQZ8co --- .../ui/podcastdetail/PodcastDetailScreen.kt | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/calmcast/podcast/ui/podcastdetail/PodcastDetailScreen.kt b/app/src/main/java/com/calmcast/podcast/ui/podcastdetail/PodcastDetailScreen.kt index b8337e8..d51b9c3 100644 --- a/app/src/main/java/com/calmcast/podcast/ui/podcastdetail/PodcastDetailScreen.kt +++ b/app/src/main/java/com/calmcast/podcast/ui/podcastdetail/PodcastDetailScreen.kt @@ -25,6 +25,13 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import com.mudita.mmd.components.search_bar.SearchBarDefaultsMMD +import com.mudita.mmd.components.text.TextMMD import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.Icon @@ -131,6 +138,40 @@ fun PodcastDetailScreen( val downloadsByEpisodeId = remember(downloads) { downloads.associateBy { it.episode.id } } + // Search within this podcast's episodes (title + description) + var isSearchOpen by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + val searchableText = remember(episodes) { + episodes.associate { episode -> + val plainDescription = (episode.description ?: "") + .replace(Regex("<[^>]*>"), " ") + .replace(" ", " ") + .replace("&", "&") + episode.id to (episode.title + " " + plainDescription).lowercase() + } + } + + val visibleEpisodes = remember(episodes, searchQuery) { + val terms = searchQuery.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() } + if (terms.isEmpty()) { + episodes + } else { + episodes.filter { episode -> + val text = searchableText[episode.id] ?: "" + terms.all { term -> text.contains(term) } + } + } + } + + LaunchedEffect(isSearchOpen) { + if (isSearchOpen) { + try { searchFocusRequester.requestFocus() } catch (_: Exception) { } + } + } + Column( modifier = Modifier.fillMaxSize() ) { @@ -172,7 +213,11 @@ fun PodcastDetailScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Episodes (${episodes.size})", + text = if (searchQuery.isNotBlank()) { + "Episodes (${visibleEpisodes.size} of ${episodes.size})" + } else { + "Episodes (${episodes.size})" + }, fontSize = 16.sp, fontWeight = FontWeight.SemiBold ) @@ -187,6 +232,26 @@ fun PodcastDetailScreen( .padding(end = 8.dp) ) } + IconButton( + onClick = { + if (isSearchOpen) { + isSearchOpen = false + searchQuery = "" + focusManager.clearFocus() + } else { + isSearchOpen = true + } + }, + modifier = Modifier + .padding(end = 16.dp) + .size(20.dp) + ) { + Icon( + imageVector = if (isSearchOpen) Icons.Outlined.Close else Icons.Outlined.Search, + contentDescription = if (isSearchOpen) "Close episode search" else "Search episodes", + modifier = Modifier.size(20.dp) + ) + } IconButton( onClick = onRefreshClick, modifier = Modifier.size(20.dp) @@ -199,6 +264,21 @@ fun PodcastDetailScreen( } } } + if (isSearchOpen) { + Spacer(modifier = Modifier.height(8.dp)) + SearchBarDefaultsMMD.InputField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = { }, + placeholder = { TextMMD("Search this podcast") }, + modifier = Modifier + .fillMaxWidth() + .padding(end = 16.dp) + .focusRequester(searchFocusRequester) + ) + } Spacer(modifier = Modifier.height(8.dp)) HorizontalDividerMMD(thickness = 3.dp) } @@ -224,6 +304,19 @@ fun PodcastDetailScreen( } } + episodes.isNotEmpty() && visibleEpisodes.isEmpty() -> { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Text("No episodes match \"${searchQuery.trim()}\"") + } + } + } + episodes.isEmpty() -> { item { Box( @@ -238,7 +331,7 @@ fun PodcastDetailScreen( } else -> { - itemsIndexed(episodes, key = { _, episode -> episode.id }) { index, episode -> + itemsIndexed(visibleEpisodes, key = { _, episode -> episode.id }) { index, episode -> val download = downloadsByEpisodeId[episode.id] val playbackPosition = playbackPositions[episode.id] val isCurrentlyPlaying = episode.id == currentPlayingEpisodeId @@ -246,7 +339,7 @@ fun PodcastDetailScreen( EpisodeItem( episode = episode, download = download, - isLastItem = index == episodes.lastIndex, + isLastItem = index == visibleEpisodes.lastIndex, playbackPosition = playbackPosition, isCurrentlyPlaying = isCurrentlyPlaying, isBuffering = isBuffering && isCurrentlyPlaying, -- 2.43.0