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
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ object HomeScreenWidgetsInstaller {
}
},
)
// Re-render when widget chrome source, Appearance theme, or lettering changes.
// Re-render when widget chrome source, Appearance theme, lettering, or the Your Shows artwork style changes.
scope.launch {
combine(
combine(
Expand All @@ -66,7 +66,8 @@ object HomeScreenWidgetsInstaller {
userPreferencesRepository.surfaceStyleStream,
) { _, _, _, _, _ -> Unit },
userPreferencesRepository.fontRoundnessStream,
) { _, _ -> Unit }.collect {
userPreferencesRepository.widgetShowsArtworkStream,
) { _, _, _ -> Unit }.collect {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
NowPlayingWidgetCoordinator.requestRefresh(context)
LibraryWidgetCoordinator.requestRefresh(context)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ class NowPlayingWidgetPlaybackAdapter(
}
}

override suspend fun refreshPosition() {
withWidgetPlaybackDispatcher(playbackDispatcher) {
playbackRepository.refreshProgressOnce()
}
}
Comment thread
ashwkun marked this conversation as resolved.
Resolved

private suspend fun restoreIfNeeded() {
withWidgetPlaybackDispatcher(playbackDispatcher) {
if (playbackRepository.playerState.value.currentEpisode == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import cx.aswin.boxlore.core.ranking.EpisodeRankingInput
import cx.aswin.boxlore.core.ranking.RankingObjective
import cx.aswin.boxlore.core.ranking.RankingSurface
import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow
import cx.aswin.boxlore.feature.widgets.WidgetLibraryCounts
import cx.aswin.boxlore.feature.widgets.WidgetLibrarySource
import cx.aswin.boxlore.feature.widgets.WidgetShowRow
import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic
Expand Down Expand Up @@ -43,7 +44,11 @@ class WidgetLibrarySourceAdapter(
private val adaptiveScorer: AdaptiveCandidateScorer,
scope: CoroutineScope,
) : WidgetLibrarySource {
private data class Projection(val subscriptions: List<WidgetShowRow>, val newEpisodes: List<WidgetEpisodeRow>,)
private data class Projection(
val subscriptions: List<WidgetShowRow>,
val newEpisodes: List<WidgetEpisodeRow>,
val counts: WidgetLibraryCounts = WidgetLibraryCounts(),
)

private val projection: Flow<Projection> =
combine(
Expand Down Expand Up @@ -84,14 +89,7 @@ class WidgetLibrarySourceAdapter(
},
)

val withLatest = enriched.filter { it.latestEpisode != null }
val latestPool =
if (inputs.hideCompleted) {
withLatest.filter { it.episodeStatus != EpisodeStatus.COMPLETED }
} else {
withLatest
}
val sortedLatest = sortLatest(latestPool, inputs.history, inputs.useSmart)
val sortedLatest = sortLatest(latestEpisodePool(enriched, inputs.hideCompleted), inputs.history, inputs.useSmart)
val episodeRows =
LibraryWidgetLogic.truncateEpisodes(
sortedLatest.mapNotNull { podcast ->
Expand All @@ -107,7 +105,11 @@ class WidgetLibrarySourceAdapter(
)
},
)
Projection(showRows, episodeRows)
Projection(
subscriptions = showRows,
newEpisodes = episodeRows,
counts = libraryCounts(sortedShows, sortedLatest),
)
Comment thread
ashwkun marked this conversation as resolved.
Resolved
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
Expand All @@ -117,6 +119,7 @@ class WidgetLibrarySourceAdapter(
override val subscriptions: Flow<List<WidgetShowRow>> = projection.map { it.subscriptions }

override val newEpisodes: Flow<List<WidgetEpisodeRow>> = projection.map { it.newEpisodes }
override val counts: Flow<WidgetLibraryCounts> = projection.map { it.counts }

private data class Inputs(
val podcasts: List<Podcast>,
Expand Down Expand Up @@ -199,6 +202,15 @@ class WidgetLibrarySourceAdapter(
private fun parseSort(name: String): ShowSort = runCatching { ShowSort.valueOf(name) }.getOrDefault(ShowSort.SmartRank)

companion object {
/** The shows whose latest episode New Episodes lists: all of them, or only the unfinished ones. */
fun latestEpisodePool(enriched: List<Podcast>, hideCompleted: Boolean): List<Podcast> {
val withLatest = enriched.filter { it.latestEpisode != null }
return if (hideCompleted) withLatest.filter { it.episodeStatus != EpisodeStatus.COMPLETED } else withLatest
}

/** The whole library's sizes, taken from the full lists before the widget's rows are capped. */
fun libraryCounts(shows: List<Podcast>, latest: List<Podcast>): WidgetLibraryCounts = WidgetLibraryCounts(subscriptions = shows.size, newEpisodes = latest.size)

fun enrichWithHistory(podcasts: List<Podcast>, allHistory: List<ListeningHistoryEntity>,): List<Podcast> = podcasts.map { podcast ->
val episode = podcast.latestEpisode ?: return@map podcast
val history = allHistory.find { it.episodeId == episode.id }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import cx.aswin.boxlore.core.database.ListeningHistoryEntity
import cx.aswin.boxlore.core.model.Episode
import cx.aswin.boxlore.core.model.EpisodeStatus
import cx.aswin.boxlore.core.model.Podcast
import cx.aswin.boxlore.feature.widgets.WidgetShowRow
import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
Expand Down Expand Up @@ -66,4 +68,38 @@ class WidgetLibrarySourceAdapterTest {
assertTrue(episode.contains("autoplay=false"))
assertTrue(episode.contains("podcastId=p1"))
}

@Test
fun countsCoverTheWholeLibraryWhileTheRowsStayCapped() {
val total = LibraryWidgetLogic.MAX_ROWS + 12
val finished = 5
val library =
List(total) { index ->
Podcast(
id = "p$index",
title = "Show $index",
artist = "Host",
imageUrl = "https://example.com/$index.jpg",
latestEpisode = Episode(id = "e$index", title = "Ep $index", description = "", audioUrl = "https://example.com/$index.mp3", publishedDate = 1_700_000_000L + index),
episodeStatus = if (index < finished) EpisodeStatus.COMPLETED else EpisodeStatus.UNPLAYED,
)
}

val everyEpisode = WidgetLibrarySourceAdapter.latestEpisodePool(library, hideCompleted = false)
val counts = WidgetLibrarySourceAdapter.libraryCounts(library, everyEpisode)
assertEquals(total, counts.subscriptions)
assertEquals(total, counts.newEpisodes)
// The widget still scrolls a capped list.
assertEquals(LibraryWidgetLogic.MAX_ROWS, LibraryWidgetLogic.truncateShows(library.map { WidgetShowRow(podcastId = it.id, title = it.title, deepLinkUri = "") }).size)

// Hiding finished episodes shrinks the New Episodes count, not the show count.
val unfinished = WidgetLibrarySourceAdapter.latestEpisodePool(library, hideCompleted = true)
val hidden = WidgetLibrarySourceAdapter.libraryCounts(library, unfinished)
assertEquals(total, hidden.subscriptions)
assertEquals(total - finished, hidden.newEpisodes)

// A show without a latest episode is a show, but not a New Episodes row.
val withoutEpisode = library + Podcast(id = "bare", title = "Bare", artist = "Host", imageUrl = "")
assertEquals(total, WidgetLibrarySourceAdapter.latestEpisodePool(withoutEpisode, hideCompleted = false).size)
}
}
2 changes: 1 addition & 1 deletion core/designsystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Owns boxlore's shared Compose visuals: theme and colour, typography, shapes, mot
### Theme and colour

- `BoxLoreTheme`: the app theme, applied by `:app` and feature roots from the saved Appearance choices.
- `resolveBoxLoreColorScheme`, `resolveBoxLoreChromeColors`, `resolveFixedThemeColorScheme`: the same scheme for widgets and Appearance previews.
- `resolveBoxLoreColorScheme`, `resolveBoxLoreChromeColors`, `resolveFixedThemeColorScheme`: the same scheme for widgets and Appearance previews. `BoxLoreChromeColors` carries the surface, primary, secondary-container and tertiary roles widgets tint with.
- `ThemePresets`: the sixteen authored light and dark themes, selected by stable `preset:*` keys.
- `CustomThemeSeeds`, `generatePersonalColorScheme`: custom accent seeds and the tonal scheme built from them.
- `ClassicBrandColors`, `BrandSeeds`, `SurfaceStyles`: the boxlore classic palette, named seeds and surface-style keys, including Pure.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ data class BoxLoreChromeColors(
@ColorInt val onPrimaryContainer: Int,
@ColorInt val secondaryContainer: Int,
@ColorInt val onSecondaryContainer: Int,
@ColorInt val tertiary: Int,
@ColorInt val onTertiary: Int,
@ColorInt val tertiaryContainer: Int,
@ColorInt val onTertiaryContainer: Int,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)

fun resolveBoxLoreChromeColors(
Expand Down Expand Up @@ -42,5 +46,9 @@ fun resolveBoxLoreChromeColors(
onPrimaryContainer = scheme.onPrimaryContainer.toArgb(),
secondaryContainer = scheme.secondaryContainer.toArgb(),
onSecondaryContainer = scheme.onSecondaryContainer.toArgb(),
tertiary = scheme.tertiary.toArgb(),
onTertiary = scheme.onTertiary.toArgb(),
tertiaryContainer = scheme.tertiaryContainer.toArgb(),
onTertiaryContainer = scheme.onTertiaryContainer.toArgb(),
)
}
2 changes: 1 addition & 1 deletion core/playback/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ src/main/java/cx/aswin/boxlore/core/data/service/ # stubs for legacy service n

- `AppContainer` builds the graph once: DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager`.
- Player and controller callbacks run on the main thread. `restoreLastSession` and transport calls hop to `PlaybackThreadPolicy.mainDispatcher`; database, artwork and recommendation work runs on background dispatchers.
- `PlaybackRepository` polls the controller position only while app UI is in the foreground. The service alone saves active-session progress and telemetry.
- `PlaybackRepository` polls the controller position only while app UI is in the foreground; `refreshProgressOnce()` publishes it once on request, and only while the player still holds the episode the state names, which home-screen widgets use while an episode plays in the background. The service alone saves active-session progress and telemetry.
- `PlaybackMediaControllerHandle.awaitController()` returns null on a rejected connection and reconnects with growing delays, at most `MediaControllerReconnectPolicy.MAX_ATTEMPTS` times.
- `BoxLorePlaybackService` resolves shared dependencies lazily after app start; its players are service-scoped, so playback and transfers continue without an Activity.
- Idle handoff: a local session paused in the background for `PlaybackPowerPolicy.PAUSED_IDLE_TIMEOUT_MS` saves progress, rechecks eligibility and stops the service. Playback, buffering intent, foreground UI and Cast skip that teardown.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ package cx.aswin.boxlore.core.playback
internal object PlaybackControllerStatePolicy {
data class Snapshot(val hasMedia: Boolean, val positionMs: Long, val bufferedPositionMs: Long, val durationMs: Long,)

/**
* Merges progress read once, outside the ticker, only when the player still holds the episode the
* state names. While the player has moved to its next item and the state still shows the previous
* episode, that progress belongs to the new item, so it waits for the state to catch up.
*/
fun mergeProgressForCurrentItem(previous: PlayerState, snapshot: Snapshot, playerEpisodeId: String?): PlayerState =
if (playerEpisodeId != null && playerEpisodeId == previous.currentEpisode?.id) mergeProgress(previous, snapshot) else previous

fun mergeProgress(previous: PlayerState, snapshot: Snapshot,): PlayerState {
if (!snapshot.hasMedia) return previous

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,23 @@ class PlaybackRepository internal constructor(
}
}

/**
* Reads the player's position once and publishes it. Home-screen widgets call this while the app
* is in the background, where [startProgressTicker] is stopped to save power. Nothing is published
* while the player is already on another item than the episode the state names.
*/
fun refreshProgressOnce() {
runOnMainThread {
val controller = mediaHandle.controller ?: return@runOnMainThread
playerStateFlow.value =
PlaybackControllerStatePolicy.mergeProgressForCurrentItem(
previous = playerStateFlow.value,
snapshot = controller.progressSnapshot(),
playerEpisodeId = controller.currentMediaItem?.mediaId?.let(PlaybackMediaIdPolicy::stripMediaIdPrefixes),
)
}
}

private fun startProgressTicker() {
stopProgressTicker()
if (!PlaybackUiVisibility.isForeground.value) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,39 @@ class PlaybackControllerStatePolicyTest {
),
)
}

@Test
fun `progress read once is published for the episode the state names`() {
val state = restoredState.copy(currentEpisode = episode("ep-1"))

val merged =
PlaybackControllerStatePolicy.mergeProgressForCurrentItem(
previous = state,
snapshot = PlaybackControllerStatePolicy.Snapshot(hasMedia = true, positionMs = 90_000L, bufferedPositionMs = 120_000L, durationMs = 1_800_000L),
playerEpisodeId = "ep-1",
)

assertEquals(90_000L, merged.position)
assertEquals(1_800_000L, merged.duration)
}

@Test
fun `progress of the player's next item is not paired with the previous episode`() {
val state = restoredState.copy(currentEpisode = episode("ep-1"))
val nextItemProgress = PlaybackControllerStatePolicy.Snapshot(hasMedia = true, positionMs = 4_000L, bufferedPositionMs = 9_000L, durationMs = 2_400_000L)

// The player moved on while the state still names the previous episode.
assertEquals(state, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(state, nextItemProgress, playerEpisodeId = "ep-2"))
// Without a known item, or without an episode in the state, there is nothing to pair it with.
assertEquals(state, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(state, nextItemProgress, playerEpisodeId = null))
assertEquals(restoredState, PlaybackControllerStatePolicy.mergeProgressForCurrentItem(restoredState, nextItemProgress, playerEpisodeId = "ep-2"))
}

private fun episode(id: String) = cx.aswin.boxlore.core.model.Episode(
id = id,
title = "Episode $id",
description = "",
audioUrl = "https://example.com/$id.mp3",
publishedDate = 1_700_000_000L,
)
}
5 changes: 3 additions & 2 deletions core/prefs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ It does not own analytics event storage, playback behaviour, ranking storage, ca
- `ThemeSelection`: opaque brand, surface and wallpaper-colour inputs; `setThemeSelection` and `setThemePreset` write them in one update, DataStore and fast cache together.
- `FontRoundnessAxis`: lettering preset keys and ROND axis values for non-Compose readers, plus the `boxlore_theme_fast_cache` file name.
- `WidgetAppearance`: sanitizes widget chrome (`app` or `system`) and reads it from the fast cache for RemoteViews.
- `WidgetShowsArtwork`: how the Your Shows widget draws covers (`rounded`, the default, or `shapes`), read from the fast cache by the widget's list factory.
- `FullPlayerStyle`, `NavigationBlurLevel`, `SubscriptionsTabStyle`: stored values and sanitizers for the full player, navigation blur and Subscriptions tab layout.
- `HeroCardStyle`, `HeroCoverShade`, `HeroTideProgress`: Home hero card style, shared colour choice and Tide ring, with per-style defaults (`HeroCoverShade.forStyle`).
- `ExploreDefaultTab`, `SubscriptionsDefaultTab`: sanitize default tabs and resolve the pager index when a route names no tab.
Expand All @@ -36,7 +37,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/
UserPreferencesRepository.kt # public repository, user_preferences delegate
UserPreferenceKeys.kt # internal Keys object
AppearancePreferences.kt # theme, lettering, navigation, landing; fast cache and hydration
ThemeSelection.kt, FontRoundnessAxis.kt, WidgetAppearance.kt
ThemeSelection.kt, FontRoundnessAxis.kt, WidgetAppearance.kt, WidgetShowsArtworkPreferences.kt
FullPlayerStyle.kt, FullPlayerPreferences.kt
NavigationBlurLevel.kt, NavigationBlurPreferences.kt
HeroCardStyle.kt, HeroCoverShade.kt, HeroTideProgress.kt, HeroCardStylePreferences.kt
Expand Down Expand Up @@ -89,7 +90,7 @@ Files:
Contracts:

- Legacy `boxcast_*` files are read through `PrefsFileMigrator`; the canonical `boxlore_*` file wins.
- The fast cache mirrors `theme_config`, `surface_style`, `theme_brand`, `use_dynamic_color`, `font_roundness`, `navigation_style`, `open_app_to`, `widget_appearance`, `explore_default_tab`, `subscriptions_default_tab`, plus the full player, navigation blur, hero, artwork-colour, custom theme and Picked for you choices.
- The fast cache mirrors `theme_config`, `surface_style`, `theme_brand`, `use_dynamic_color`, `font_roundness`, `navigation_style`, `open_app_to`, `widget_appearance`, `widget_shows_artwork`, `explore_default_tab`, `subscriptions_default_tab`, plus the full player, navigation blur, hero, artwork-colour, custom theme and Picked for you choices.
- After an Android backup restore, `hydrateMissingDataStoreFromFastCache` copies cached values only into missing DataStore keys; an existing DataStore choice always wins.
- Hero choices are cached and hydrated only once picked, so listeners who never picked follow the current default.
- `theme_brand` holds a named palette, a `#RRGGBB` Material 3 seed, or `exact:#RRGGBB` (primary used as is).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ internal class AppearancePreferences(
val fullPlayer = FullPlayerPreferences(dataStore, syncPrefs)
val navigationBlur = NavigationBlurPreferences(dataStore, syncPrefs)
val heroCard = HeroCardStylePreferences(dataStore, syncPrefs)
val widgetShowsArtwork = WidgetShowsArtworkPreferences(dataStore, syncPrefs)

val cachedThemeBrand: String
get() = syncPrefs.getString("theme_brand", null) ?: "violet"
Expand Down Expand Up @@ -355,6 +356,7 @@ internal class AppearancePreferences(
navigationBlur.hydrate(preferences)
heroCard.hydrate(preferences)
preferences[Keys.WIDGET_APPEARANCE] = preferences[Keys.WIDGET_APPEARANCE] ?: cachedWidgetAppearance
widgetShowsArtwork.hydrate(preferences)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ internal object Keys {
androidx.datastore.preferences.core
.booleanPreferencesKey("home_shortcuts_in_library")
val WIDGET_APPEARANCE = stringPreferencesKey("widget_appearance")
val WIDGET_SHOWS_ARTWORK = stringPreferencesKey("widget_shows_artwork")
val AUTO_ORGANIZE_FOLDERS_ENABLED =
androidx.datastore.preferences.core
.booleanPreferencesKey("auto_organize_folders_enabled")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,13 @@ class UserPreferencesRepository(context: Context,) {

suspend fun setWidgetAppearance(appearance: String) = this.appearance.setWidgetAppearance(appearance)

val cachedWidgetShowsArtwork: String
get() = appearance.widgetShowsArtwork.cachedStyle

val widgetShowsArtworkStream: Flow<String> = appearance.widgetShowsArtwork.style

suspend fun setWidgetShowsArtwork(style: String) = appearance.widgetShowsArtwork.setStyle(style)

val overriddenRecPodcastIdStream: Flow<String?> = library.overriddenRecPodcastIdStream

suspend fun setOverriddenRecPodcastId(podcastId: String?) = library.setOverriddenRecPodcastId(podcastId)
Expand Down
Loading
Loading