Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 13 additions & 11 deletions feature/home/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ import cx.aswin.boxlore.core.model.Podcast

internal data class SelectedPodcastSignal(
val podcastId: String,
val lastPlayedEpisodeId: String?,
/** Shows listened to in order: the listener's place (history ID); null for newest-first shows. */
val placeHistoryId: String?,
val sort: String,
val rssRefreshVersion: Long,
val latestEpisodeId: String? = null,
val rssHasNewEpisodes: Boolean = false,
)
/** Bumped by Try again after a failed load. */
val retryVersion: Long = 0L,
) {
val inOrder: Boolean get() = sort == "oldest"
}

data class HomeDataWrapper(
val trending: List<Podcast>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ internal data class PodcastFeedContent(
val gridItems: StablePodcastList,
val recommendations: StableEpisodeList,
val selectedPodcastEpisodes: StableEpisodeList,
val selectedShowStatus: SelectedShowStatus = SelectedShowStatus(),
)

@androidx.compose.runtime.Stable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ private fun YourShowsFeedContent(
isSelectedPodcastLoading = loadingState.isSelectedPodcastLoading,
isSelectedRssRefreshing = loadingState.isSelectedRssRefreshing,
episodePlaybackState = playback.episodePlaybackState,
selectedShowStatus = content.selectedShowStatus,
onRetrySelectedShow = callbacks.onRetrySelectedShow,
softExpireProgressEpisodeIds = playback.softExpireProgressEpisodeIds,
currentPlayingEpisodeId = playback.player.currentPlayingEpisodeId,
isPlaying = playback.player.isPlaying,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ data class HomeFeedCallbacks(
val onFeedbackClick: () -> Unit,
val onToggleHomePin: (String) -> Unit = {},
val onDismissFeaturedVideoShowcaseForever: () -> Unit = {},
val onRetrySelectedShow: () -> Unit = {},
)

@androidx.compose.runtime.Stable
Expand Down Expand Up @@ -346,6 +347,7 @@ fun HomeRoute(
onFeedbackClick = onFeedbackClick ?: viewModel::triggerFeedback,
onToggleHomePin = viewModel::toggleHomePin,
onDismissFeaturedVideoShowcaseForever = viewModel::dismissFeaturedVideoShowcaseForever,
onRetrySelectedShow = viewModel::retrySelectedShow,
),
onNavigateToSettings = onNavigateToSettings,
onForceReviewPrompt = viewModel::forceReviewPrompt,
Expand Down Expand Up @@ -440,6 +442,7 @@ private fun HomeScreenFeedContent(
gridItems = StablePodcastList(uiState.discoverPodcasts),
recommendations = StableEpisodeList(uiState.recommendations),
selectedPodcastEpisodes = StableEpisodeList(uiState.selectedPodcastEpisodes),
selectedShowStatus = uiState.selectedShowStatus,
),
feedState =
PodcastFeedUiState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ data class SmartHeroItem(

enum class HeroType { RESUME, RESUME_GRID, JUMP_BACK_IN, NEW_EPISODES_GRID, SPOTLIGHT }

/** How the selected show's episode list on Home stands, beside its episodes. */
@Immutable
data class SelectedShowStatus(
/** Nothing is left to hear, so the list holds the show's latest episodes. */
val caughtUp: Boolean = false,
/** The last load failed with nothing to show. */
val loadFailed: Boolean = false,
/** The show's two newest episodes, the only ones that can carry the NEW tag. */
val newestEpisodeIds: Set<String> = emptySet(),
)

enum class HomeEditorialIcon {
HEADLINES,
UPLIFTING,
Expand Down Expand Up @@ -62,6 +73,7 @@ data class HomeUiState(
val isError: Boolean = false,
val selectedPodcastId: String? = null,
val selectedPodcastEpisodes: List<Episode> = emptyList(),
val selectedShowStatus: SelectedShowStatus = SelectedShowStatus(),
val isSelectedPodcastLoading: Boolean = false,
val isSelectedRssRefreshing: Boolean = false,
val episodePlaybackState: Map<String, Pair<EpisodeStatus, Float>> = emptyMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,10 @@ class HomeViewModel(
internal var activeRegion = "us"

internal val _selectedPodcastId = MutableStateFlow<String?>(null)
internal val _selectedPodcastEpisodes = MutableStateFlow<List<Episode>>(emptyList())
internal val _selectedShowPool = MutableStateFlow<cx.aswin.boxlore.feature.home.logic.ShowEpisodesPool?>(null)
internal val _isSelectedPodcastLoading = MutableStateFlow(false)
internal val _selectedShowLoadFailed = MutableStateFlow(false)
internal val _selectedShowRetryVersion = MutableStateFlow(0L)
internal val _isSelectedRssRefreshing = MutableStateFlow(false)
internal val _rssRefreshVersion = MutableStateFlow(0L)
internal val rssFeedsRefreshedThisSession =
Expand All @@ -358,6 +360,9 @@ class HomeViewModel(
internal val _resolvedSerialEpisodes = MutableStateFlow<Map<String, Episode>>(emptyMap())
internal val inFlightResolutions = java.util.Collections.synchronizedSet(mutableSetOf<String>())

/** Shows listened to in order that have nothing left to suggest, by the place and newest episode they were checked at. */
internal val serialCaughtUpKeys = java.util.concurrent.ConcurrentHashMap<String, String>()

// True when the current filter selection was applied automatically (single-show state)
// rather than by an explicit user tap. Used to decide whether to fall back to the mix
// when the subscription set grows past one show.
Expand All @@ -367,6 +372,11 @@ class HomeViewModel(
applySelection(podcastId, isAuto = false)
}

/** Try again after the selected show's episodes failed to load. */
fun retrySelectedShow() {
_selectedShowRetryVersion.value += 1L
}

private fun applySelection(
podcastId: String?,
isAuto: Boolean,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,101 +6,67 @@ import cx.aswin.boxlore.core.playback.completedEpisodeIds
import cx.aswin.boxlore.core.playback.episodeGroupTokens
import cx.aswin.boxlore.feature.home.logic.HomeForegroundSyncLogic
import cx.aswin.boxlore.feature.home.logic.HomeSelectedPodcastLogic
import cx.aswin.boxlore.feature.home.logic.HomeSerialLogic
import cx.aswin.boxlore.feature.home.logic.HomeShowEpisodesLogic
import cx.aswin.boxlore.feature.home.logic.HomeYourShowsSignals
import cx.aswin.boxlore.feature.home.logic.SelectedShowLoadState
import cx.aswin.boxlore.feature.home.logic.SelectedShowLoadTransitions
import cx.aswin.boxlore.feature.home.logic.SeriesEpisodesLoader
import cx.aswin.boxlore.feature.home.logic.ShowEpisodesList
import cx.aswin.boxlore.feature.home.logic.ShowEpisodesPool
import cx.aswin.boxlore.feature.home.logic.settledCatalogChanges
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

internal fun HomeViewModel.observeSelectedPodcast() {
viewModelScope.launch {
val historySignal =
val signal =
combine(
_selectedPodcastId,
allHomeHistory,
subscriptionRepository.subscribedPodcasts,
_rssRefreshVersion,
) { podcastId, allHistory, subs, rssRefreshVersion ->
_selectedShowRetryVersion,
) { podcastId, allHistory, subs, rssRefreshVersion, retryVersion ->
HomeSelectedPodcastLogic.buildSignal(
podcastId = podcastId,
allHistory = allHistory,
subs = subs,
rssRefreshVersion = rssRefreshVersion,
retryVersion = retryVersion,
)
}.distinctUntilChanged()

var previouslyLoadedPodcastId: String? = null
historySignal.collectLatest { info ->
var state = SelectedShowLoadTransitions.cleared
fun publish(next: SelectedShowLoadState) {
state = next
_selectedShowPool.value = next.pool
_isSelectedPodcastLoading.value = next.loading
_selectedShowLoadFailed.value = next.failed
}
signal.collectLatest { info ->
if (info == null) {
android.util.Log.d("HomeViewModelFilteredView", "historySignal collected: null (clearing selected podcast episodes)")
_selectedPodcastEpisodes.value = emptyList()
_isSelectedPodcastLoading.value = false
publish(SelectedShowLoadTransitions.cleared)
previouslyLoadedPodcastId = null
} else {
val (podcastId, lastPlayedEpisodeId, sort) = info
android.util.Log.d(
"HomeViewModelFilteredView",
"historySignal collected: podcastId=$podcastId, lastPlayedEpisodeId=$lastPlayedEpisodeId, sort=$sort",
)
val switchingPodcast = podcastId != previouslyLoadedPodcastId
if (switchingPodcast) {
_selectedPodcastEpisodes.value = emptyList()
previouslyLoadedPodcastId = podcastId
}
val showLoading =
HomeSelectedPodcastLogic.shouldShowLoading(
switchingPodcast = switchingPodcast,
listEmpty = _selectedPodcastEpisodes.value.isEmpty(),
)
_isSelectedPodcastLoading.value = showLoading
try {
if (sort == "oldest") {
val page =
podcastRepository.getEpisodesPaginated(
podcastId,
limit = 100,
offset = 0,
sort = "oldest",
)
// History keeps an episode under its owner ID; find it under the ID the list uses.
val tokens = playbackRepository.episodeGroupTokens(page.episodes.map { it.id } + listOfNotNull(lastPlayedEpisodeId))
val selectedRaw =
HomeSelectedPodcastLogic.oldestSortWindow(
allEpisodes = page.episodes,
lastPlayedEpisodeId = HomeSerialLogic.idInList(lastPlayedEpisodeId, page.episodes, tokens),
)
android.util.Log.d(
"HomeViewModelFilteredView",
"Oldest sort resolution for podcastId=$podcastId: " +
"totalEpisodesFetched=${page.episodes.size}, " +
"rawEpisodesSelectedCount=${selectedRaw.size}",
)
_selectedPodcastEpisodes.value = selectedRaw
} else {
val page =
podcastRepository.getEpisodesPaginated(
podcastId,
limit = 25,
offset = 0,
sort = "newest",
)
_selectedPodcastEpisodes.value = page.episodes
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e("HomeViewModelFilteredView", "Failed to fetch episodes for filter: $podcastId", e)
if (showLoading) {
_selectedPodcastEpisodes.value = emptyList()
}
} finally {
_isSelectedPodcastLoading.value = false
}
return@collectLatest
}
val switchingPodcast = info.podcastId != previouslyLoadedPodcastId
previouslyLoadedPodcastId = info.podcastId
publish(SelectedShowLoadTransitions.started(state, switchingPodcast))
try {
publish(SelectedShowLoadTransitions.succeeded(loadSelectedShowPool(info)))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e("HomeViewModelFilteredView", "Failed to fetch episodes for filter: ${info.podcastId}", e)
publish(SelectedShowLoadTransitions.failed(state))
}
}
}
Expand All @@ -118,32 +84,106 @@ internal fun HomeViewModel.observeSelectedPodcast() {
}

viewModelScope.launch {
val frame =
combine(
_selectedPodcastId,
_selectedShowPool,
_isSelectedPodcastLoading,
_selectedShowLoadFailed,
) { id, pool, loading, failed ->
// A pool still held from the previous show never pairs with the new one.
SelectedShowFrame(id, pool?.takeIf { it.podcastId == id }, loading, failed)
}
combine(
_selectedPodcastId,
_selectedPodcastEpisodes,
_isSelectedPodcastLoading,
frame,
userPrefs.hideCompletedInHomeStream,
playbackRepository.completedEpisodeIds,
) { id, eps, loading, hideCompleted, completedIds ->
val filteredEps =
HomeSelectedPodcastLogic.filterCompletedIfNeeded(
episodes = eps,
hideCompleted = hideCompleted,
completedIds = completedIds,
)
Triple(id, filteredEps, loading)
}.collect { (id, eps, loading) ->
_uiState.update {
it.copy(
selectedPodcastId = id,
selectedPodcastEpisodes = eps,
isSelectedPodcastLoading = loading,
)
) { current, hideCompleted, completedIds ->
val list =
current.pool?.let { HomeShowEpisodesLogic.build(it, completedIds, hideCompleted) }
?: ShowEpisodesList(emptyList())
current to list
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
.collect { (current, list) ->
_uiState.update {
it.copy(
selectedPodcastId = current.podcastId,
selectedPodcastEpisodes = list.episodes,
selectedShowStatus =
SelectedShowStatus(
caughtUp = list.caughtUp,
loadFailed = current.loadFailed && list.episodes.isEmpty(),
newestEpisodeIds = list.newestIds,
),
isSelectedPodcastLoading = current.loading,
)
}
}
}
}
}

private data class SelectedShowFrame(
val podcastId: String?,
val pool: ShowEpisodesPool?,
val loading: Boolean,
val loadFailed: Boolean,
)

/** The selected show's episodes to build Home's list from: from the listener's place for a show listened to in order, else its newest. */
private suspend fun HomeViewModel.loadSelectedShowPool(info: SelectedPodcastSignal): ShowEpisodesPool {
if (!info.inOrder) {
val newest =
podcastRepository.getEpisodesPaginated(
info.podcastId,
limit = HomeShowEpisodesLogic.NEWEST_POOL_SIZE,
offset = 0,
sort = "newest",
).episodes
return ShowEpisodesPool(podcastId = info.podcastId, inOrder = false, episodes = newest)
}
val completedIds = playbackRepository.completedEpisodeIds.first()
val loaded = seriesEpisodesLoader(info.podcastId).load(info.placeHistoryId, completedIds)
val newest =
try {
podcastRepository.getEpisodesPaginated(
info.podcastId,
limit = HomeShowEpisodesLogic.NEWEST_FOR_IN_ORDER,
offset = 0,
sort = "newest",
).episodes
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Only the NEW tags and the caught-up list use these; the list itself still stands.
android.util.Log.w("HomeViewModelFilteredView", "Newest episodes failed for ${info.podcastId}", e)
emptyList()
}
return ShowEpisodesPool(
podcastId = info.podcastId,
inOrder = true,
episodes = loaded.episodes,
placeId = loaded.placeId,
newest = newest,
)
}

/** Reads a show listened to in order from the listener's place on; shared by the selected-show list and Daily mix. */
internal fun HomeViewModel.seriesEpisodesLoader(podcastId: String): SeriesEpisodesLoader = SeriesEpisodesLoader(
window = { aroundId -> podcastRepository.getEpisodeWindow(podcastId, aroundEpisodeId = aroundId, sort = "oldest") },
oldestPage = { offset ->
val page =
podcastRepository.getEpisodesPaginated(
podcastId,
limit = SeriesEpisodesLoader.PAGE_SIZE,
offset = offset,
sort = "oldest",
)
SeriesEpisodesLoader.Page(page.episodes, hasMore = page.hasMore, advance = page.sourceCount)
},
groupTokens = { ids -> playbackRepository.episodeGroupTokens(ids) },
)

internal fun HomeViewModel.observeDirectFeedRefresh() {
viewModelScope.launch {
val sync = SharedAppDependenciesHolder.instance?.subscriptionForegroundSync ?: return@launch
Expand Down
Loading
Loading