diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt index 4b649fa43..c94ae2bfb 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/HomeScreenWidgetsInstaller.kt @@ -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( @@ -66,7 +66,8 @@ object HomeScreenWidgetsInstaller { userPreferencesRepository.surfaceStyleStream, ) { _, _, _, _, _ -> Unit }, userPreferencesRepository.fontRoundnessStream, - ) { _, _ -> Unit }.collect { + userPreferencesRepository.widgetShowsArtworkStream, + ) { _, _, _ -> Unit }.collect { NowPlayingWidgetCoordinator.requestRefresh(context) LibraryWidgetCoordinator.requestRefresh(context) } diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt b/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt index fb4028aa2..542effab2 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/NowPlayingWidgetPlaybackAdapter.kt @@ -77,6 +77,12 @@ class NowPlayingWidgetPlaybackAdapter( } } + override suspend fun refreshPosition() { + withWidgetPlaybackDispatcher(playbackDispatcher) { + playbackRepository.refreshProgressOnce() + } + } + private suspend fun restoreIfNeeded() { withWidgetPlaybackDispatcher(playbackDispatcher) { if (playbackRepository.playerState.value.currentEpisode == null) { diff --git a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt index fe734eb2b..de403ed6a 100644 --- a/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt +++ b/app/src/main/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapter.kt @@ -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 @@ -43,7 +44,11 @@ class WidgetLibrarySourceAdapter( private val adaptiveScorer: AdaptiveCandidateScorer, scope: CoroutineScope, ) : WidgetLibrarySource { - private data class Projection(val subscriptions: List, val newEpisodes: List,) + private data class Projection( + val subscriptions: List, + val newEpisodes: List, + val counts: WidgetLibraryCounts = WidgetLibraryCounts(), + ) private val projection: Flow = combine( @@ -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 -> @@ -107,7 +105,11 @@ class WidgetLibrarySourceAdapter( ) }, ) - Projection(showRows, episodeRows) + Projection( + subscriptions = showRows, + newEpisodes = episodeRows, + counts = libraryCounts(sortedShows, sortedLatest), + ) }.stateIn( scope = scope, started = SharingStarted.Eagerly, @@ -117,6 +119,7 @@ class WidgetLibrarySourceAdapter( override val subscriptions: Flow> = projection.map { it.subscriptions } override val newEpisodes: Flow> = projection.map { it.newEpisodes } + override val counts: Flow = projection.map { it.counts } private data class Inputs( val podcasts: List, @@ -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, hideCompleted: Boolean): List { + 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, latest: List): WidgetLibraryCounts = WidgetLibraryCounts(subscriptions = shows.size, newEpisodes = latest.size) + fun enrichWithHistory(podcasts: List, allHistory: List,): List = podcasts.map { podcast -> val episode = podcast.latestEpisode ?: return@map podcast val history = allHistory.find { it.episodeId == episode.id } diff --git a/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt b/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt index 619389c02..bc9a1e21a 100644 --- a/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt +++ b/app/src/test/java/cx/aswin/boxlore/widgets/WidgetLibrarySourceAdapterTest.kt @@ -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 @@ -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) + } } diff --git a/core/designsystem/README.md b/core/designsystem/README.md index 88e95ba84..d97b55b57 100644 --- a/core/designsystem/README.md +++ b/core/designsystem/README.md @@ -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. diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt index d5d3a494e..03dea8a99 100644 --- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt +++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/BoxLoreChromeColors.kt @@ -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, ) fun resolveBoxLoreChromeColors( @@ -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(), ) } diff --git a/core/playback/README.md b/core/playback/README.md index 680dbacf3..1992dc9f0 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -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. diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt index 9a32351dc..98a702d92 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicy.kt @@ -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 diff --git a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt index bdc71947a..767befb28 100644 --- a/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt +++ b/core/playback/src/main/java/cx/aswin/boxlore/core/playback/PlaybackRepository.kt @@ -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 diff --git a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt index 16f682c71..e57956509 100644 --- a/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt +++ b/core/playback/src/test/java/cx/aswin/boxlore/core/playback/PlaybackControllerStatePolicyTest.kt @@ -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, + ) } diff --git a/core/prefs/README.md b/core/prefs/README.md index ffdebf079..ab6f8a894 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -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. @@ -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 @@ -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). diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt index dbf78c12b..7caec672a 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt @@ -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" @@ -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) } } diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt index 23e4d13a9..cbaf514de 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferenceKeys.kt @@ -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") diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt index 09780f5d1..d189f669d 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepository.kt @@ -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 = appearance.widgetShowsArtwork.style + + suspend fun setWidgetShowsArtwork(style: String) = appearance.widgetShowsArtwork.setStyle(style) + val overriddenRecPodcastIdStream: Flow = library.overriddenRecPodcastIdStream suspend fun setOverriddenRecPodcastId(podcastId: String?) = library.setOverriddenRecPodcastId(podcastId) diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt index 3f3ee10a7..22a469bed 100644 --- a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetAppearance.kt @@ -24,3 +24,27 @@ object WidgetAppearance { return sanitize(prefs.getString(PREF_KEY, null)) } } + +/** + * How the Your Shows widget draws artwork: whole covers as rounded squares (default), or covers cut + * into mixed Material shapes. + */ +object WidgetShowsArtwork { + const val ROUNDED = "rounded" + const val SHAPES = "shapes" + + const val PREF_KEY = "widget_shows_artwork" + + fun sanitize(value: String?): String { + val normalized = value?.trim()?.lowercase() + return if (normalized == SHAPES) SHAPES else ROUNDED + } + + fun usesShapes(value: String?): Boolean = sanitize(value) == SHAPES + + fun cached(context: Context): String { + val prefs = + context.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + return sanitize(prefs.getString(PREF_KEY, null)) + } +} diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt new file mode 100644 index 000000000..235ccde58 --- /dev/null +++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/WidgetShowsArtworkPreferences.kt @@ -0,0 +1,41 @@ +package cx.aswin.boxlore.core.prefs + +import android.content.SharedPreferences +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * How the Your Shows widget draws covers ([WidgetShowsArtwork]); whole covers by default. Mirrored in + * the theme fast-cache so the widget's list factory can read it without DataStore. + */ +internal class WidgetShowsArtworkPreferences(private val store: DataStore, private val cache: SharedPreferences) { + val cachedStyle: String + get() = WidgetShowsArtwork.sanitize(cache.getString(WidgetShowsArtwork.PREF_KEY, null)) + + val style = store.data + .catch { exception -> + if (exception is IOException) emit(emptyPreferences()) else throw exception + }.map { preferences -> + val style = preferences[Keys.WIDGET_SHOWS_ARTWORK]?.let(WidgetShowsArtwork::sanitize) ?: cachedStyle + cache.edit().putString(WidgetShowsArtwork.PREF_KEY, style).apply() + style + }.distinctUntilChanged() + + suspend fun setStyle(style: String) { + val sanitized = WidgetShowsArtwork.sanitize(style) + // The cache is written first: the widget refresh this triggers reads it straight away. + cache.edit().putString(WidgetShowsArtwork.PREF_KEY, sanitized).apply() + store.edit { it[Keys.WIDGET_SHOWS_ARTWORK] = sanitized } + } + + fun hydrate(preferences: MutablePreferences) { + preferences[Keys.WIDGET_SHOWS_ARTWORK] = preferences[Keys.WIDGET_SHOWS_ARTWORK] ?: cachedStyle + } +} diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt index 31b0414ac..c0074b9c6 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/UserPreferencesRepositoryTest.kt @@ -604,6 +604,19 @@ class UserPreferencesRepositoryTest { assertEquals(WidgetAppearance.APP, repository.widgetAppearanceStream.first()) } + @Test + fun widgetShowsArtworkDefaultsToWholeCoversAndPersists() = runTest { + assertEquals(WidgetShowsArtwork.ROUNDED, repository.widgetShowsArtworkStream.first()) + assertEquals(WidgetShowsArtwork.ROUNDED, repository.cachedWidgetShowsArtwork) + repository.setWidgetShowsArtwork(WidgetShowsArtwork.SHAPES) + assertEquals(WidgetShowsArtwork.SHAPES, repository.widgetShowsArtworkStream.first()) + // The widget's list factory reads the cache, so it must change with the setting. + assertEquals(WidgetShowsArtwork.SHAPES, repository.cachedWidgetShowsArtwork) + repository.setWidgetShowsArtwork("unknown") + assertEquals(WidgetShowsArtwork.ROUNDED, repository.widgetShowsArtworkStream.first()) + assertEquals(WidgetShowsArtwork.ROUNDED, repository.cachedWidgetShowsArtwork) + } + @Test fun overriddenRecPodcastIdSetAndClear() = runTest { assertNull(repository.overriddenRecPodcastIdStream.first()) diff --git a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt index be39ea35a..c001d1a9c 100644 --- a/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt +++ b/core/prefs/src/test/java/cx/aswin/boxlore/core/prefs/WidgetAppearanceTest.kt @@ -20,4 +20,14 @@ class WidgetAppearanceTest { assertTrue(WidgetAppearance.followsAppTheme(WidgetAppearance.APP)) assertFalse(WidgetAppearance.followsAppTheme(WidgetAppearance.SYSTEM)) } + + @Test + fun showsArtworkDefaultsToWholeCoversAndShapesAreOptIn() { + assertEquals(WidgetShowsArtwork.ROUNDED, WidgetShowsArtwork.sanitize(null)) + assertEquals(WidgetShowsArtwork.ROUNDED, WidgetShowsArtwork.sanitize("unknown")) + assertEquals(WidgetShowsArtwork.SHAPES, WidgetShowsArtwork.sanitize(" Shapes ")) + assertFalse(WidgetShowsArtwork.usesShapes(null)) + assertFalse(WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.ROUNDED)) + assertTrue(WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.SHAPES)) + } } diff --git a/feature/widgets/README.md b/feature/widgets/README.md index a8780d011..bba713471 100644 --- a/feature/widgets/README.md +++ b/feature/widgets/README.md @@ -2,45 +2,51 @@ ## Purpose -Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteViews` (no Glance): four playback widgets and two library list widgets. It renders snapshots supplied through narrow ports and does not construct `PlaybackRepository` or `SubscriptionRepository`, or talk to Media3 or Room directly; `:app` installs the ports. +Owns boxlore's home-screen widgets, built with `AppWidgetProvider` and `RemoteViews` (no Glance): four playback widgets and two library widgets, drawn as Material 3 Expressive shapes (pills, circles and a scalloped "cookie") on a transparent root. It renders snapshots supplied through narrow ports and does not construct `PlaybackRepository` or `SubscriptionRepository`, or talk to Media3 or Room directly; `:app` installs the ports. ## Widgets | Picker label | Receiver | Contents | | :--- | :--- | :--- | -| Now Playing · Full Controls | `NowPlayingWidgetReceiver` | Artwork, titles, previous, seek back, play-pause, seek forward, next | -| Now Playing · Compact | `NowPlayingBarWidgetReceiver` | One-row bar with artwork, titles and seek controls | -| Playback Controls · Seek | `PlaybackControlsWidgetReceiver` | Square grid: artwork, play-pause, seek back and forward | -| Playback Controls · Next | `PlaybackNextControlsWidgetReceiver` | Square grid: artwork, play-pause, next episode and seek forward | -| Your Shows | `SubscriptionsWidgetReceiver` | Scrollable list of subscribed shows, in Library's Shows order | -| New Episodes | `NewEpisodesWidgetReceiver` | Scrollable list of latest episodes, with Library's New Episodes filters | - -- There is no shuffle or repeat on widgets. Library list widgets never play audio; rows open the app. -- When playback is dismissed, every playback widget becomes a branded launch surface ("Ready when you are") that opens boxlore, never disabled transport. -- Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`. Row taps open the deep link `:app` supplies on each row; the footer opens `boxlore://library/subscriptions?tab=0` or `?tab=1`. -- Missing or unreadable artwork falls back to show or episode candidates, then to the shared boxlore mark. +| Now Playing · Full Controls | `NowPlayingWidgetReceiver` | Info pill (round artwork in a progress ring, title, time left and show) over a row of previous and seek back, play-pause, seek forward and next | +| Now Playing · Compact | `NowPlayingBarWidgetReceiver` | One pill with artwork in its progress ring, the episode title and time left (the artwork names the show), seek back, play-pause and seek forward | +| Now Playing · Round | `PlaybackControlsWidgetReceiver` | Round widget: artwork inside a progress ring, with seek back, play-pause and seek forward along the bottom | +| Now Playing · Round with Next | `PlaybackNextControlsWidgetReceiver` | Round widget: artwork inside a progress ring, with seek forward, play-pause and next episode | +| Library · Your Shows | `SubscriptionsWidgetReceiver` | Scrollable grid of subscribed shows' artwork, in Library's Shows order, with a dot on shows that have a new episode | +| Library · New Episodes | `NewEpisodesWidgetReceiver` | Scrollable rows of latest episodes, with Library's New Episodes filters | + +- There is no shuffle or repeat on widgets. Library widgets never play audio; rows and tiles open the app. +- The play button is a circle at rest and the scalloped cookie while playing. Seek icons show the listener's own seek lengths. +- When playback is dismissed, every playback widget becomes a launch surface ("Ready when you are") that opens boxlore, never disabled transport. +- Library lists are capped at `LibraryWidgetLogic.MAX_ROWS`; the header badge counts the whole library through `WidgetLibraryCounts`. Row and tile taps open the deep link `:app` supplies; the header and its arrow open `boxlore://library/subscriptions?tab=0` or `?tab=1`. +- Your Shows draws whole covers as rounded squares, or cuts them into mixed shapes when the `WidgetShowsArtwork` preference says so. +- Missing or unreadable artwork falls back to show or episode candidates, then to a tinted placeholder with the boxlore mark, cut to the same shape. ## Public API - The six receivers above: picker-visible providers. - `WidgetControlReceiver`: one explicit, non-exported action endpoint shared by the playback widgets. - `WidgetActionIntents`: builds broadcast, open-app and deep-link `PendingIntent`s with `SecurityException` guards. `cancelAll(context, appWidgetId)` and `LibraryWidgetRenderer.cancelAll` cancel a deleted widget's intents so the app never exhausts the system's `PendingIntent` quota. -- `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. -- `LibraryWidgetDependencies`, `configureLibraryWidgets(...)` and `WidgetLibrarySource`: the library port. +- `NowPlayingWidgetDependencies`, `configureNowPlayingWidget(...)` and `WidgetPlaybackSource`: the playback port. `refreshPosition()` asks the player for its position, which the app only tracks while it is on screen. +- `LibraryWidgetDependencies`, `configureLibraryWidgets(...)` and `WidgetLibrarySource`: the library port, with capped lists and uncapped `counts`. - `NowPlayingWidgetSnapshotStore` and `LibraryWidgetSnapshotStore`: persisted snapshots each widget renders from. ## Rendering and theming - Coordinators collect the ports' flows, persist snapshots, render `RemoteViews` and load artwork without blocking metadata; cancellation keeps an outdated artwork result from replacing newer metadata. +- Playback layouts scale themselves: rows share the height by weight, squares take their width from their height, icons are fractions of their button and text shrinks to fit, inside a frame that keeps the widget's proportions and centres it in the cell. Nothing is sized from what the launcher reports, so a widget fits phones, tablets, foldables and any resize. The round widgets stay a circle as wide as the cell's shorter side, and Your Shows picks its own column count. +- What a playback widget shows (title lines, the time left, the show name, Full Controls' taller shape) changes at the size steps in `WidgetPresentation`. Each update carries one layout per step and the launcher draws the largest that fits the space it really has, also while resizing. Reported sizes (`WidgetSizes`) only decide how sharp the pictures are. +- While an episode plays and a playback widget is placed, `WidgetProgressPoller` asks the port for the position every `PROGRESS_POLL_MS`. `WidgetUpdatePolicy` repaints only when the ring or the time-left label would change, about once a minute. - Both renderers push through `pushWidgetUpdate`. From Android 17, an update over the launcher's bitmap cap throws; the guard catches it, retries once with a lighter update where one exists, and otherwise waits for the next update, so the player service never crashes. -- Artwork is decoded to fit the widget, and text bitmaps (`WidgetTextBitmapRenderer`) to the label's own box, keeping updates under that cap. -- Widget chrome follows the in-app Appearance theme by default (`WidgetChrome` bakes colours from the theme cache). Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. -- `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme preferences change. -- Picker previews (`widget_preview_*.xml`) mirror each live variant on themed surfaces. +- Artwork is decoded to fit the widget and cut to its shape (`WidgetShapes`), keeping updates under that cap. `WidgetBitmapCache` keeps cut artwork, rings, masks and seek icons in memory, so a tap redraws without decoding anything again. +- Library covers load a few at a time and each batch is shown as it lands (`LibraryArtworkBatches`); a newer library waits for the older load to stop before it saves. The list service also fetches a missing cover itself, so covers appear while the app is closed; a tile shows its placeholder until then, and a cover that ran out of time is left alone for a while (`ArtworkTimeoutCooldown`). +- Every colour is a tint, never baked into a bitmap. Widget chrome follows the in-app Appearance theme by default: `WidgetChrome` sends one ARGB value for a fixed Light or Dark display mode, and a light and dark pair when the display mode follows the system, so the launcher switches with the phone while the app is not running. Appearance › Widgets can switch to System, which uses the system's dynamic colour resources. +- `WidgetThemeSync` re-pushes widgets on configuration changes; `:app` refreshes them when the theme or widget preferences change. +- Picker previews (`widget_preview_*.xml`) are static layouts in resource colours. ## Design provenance -The playback widgets are an independent `RemoteViews` implementation of the widget hierarchy measured from PixelPlayerOSS. No GPL source, app assets, names or resources are copied into boxlore. Transport icons come independently from Google Material Symbols under Apache-2.0. boxlore keeps its own PolyForm Strict licensing and implementation. +The widgets are boxlore's own design in Material 3 Expressive shapes, drawn in code and vector drawables written for this module. Transport icons come from Google Material Symbols under Apache-2.0. boxlore keeps its own PolyForm Strict licensing and implementation. ## Internal structure @@ -49,10 +55,13 @@ src/main/java/cx/aswin/boxlore/feature/widgets/ NowPlayingWidget*.kt # receivers, dependencies, coordinator, renderer, models, snapshot store LibraryWidget*.kt # receivers, dependencies, coordinator, renderer, snapshot store, # RemoteViewsService for the lists - WidgetArtworkLoader.kt, WidgetChrome.kt, WidgetPalette.kt, WidgetRemoteViewsColors.kt, - WidgetTextBitmapRenderer.kt, WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt + WidgetArtworkLoader.kt, WidgetBitmapCache.kt, WidgetChrome.kt, WidgetPalette.kt, + WidgetRemoteViewsColors.kt, WidgetShapes.kt, WidgetSeekIcon.kt, + WidgetThemeSync.kt, WidgetHostPresence.kt, WidgetUpdates.kt actions/ # WidgetActionIntents, WidgetActionHandler, WidgetControlReceiver - logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic + logic/ # NowPlayingWidgetLogic, LibraryWidgetLogic, WidgetPresentation, WidgetSizes, + # WidgetProgress, WidgetProgressPoller, LibraryArtworkBatches, + # ArtworkTimeoutCooldown src/main/res/ layout/ # live layouts and picker previews xml/*_widget_info.xml # provider info @@ -60,7 +69,7 @@ src/main/res/ ## Dependencies -- → `:core:model`, `:core:designsystem` (font and theme colours), `:core:prefs` (`FontRoundnessAxis`, `WidgetAppearance`). +- → `:core:model`, `:core:designsystem` (font and theme colours), `:core:prefs` (`FontRoundnessAxis`, `WidgetAppearance`, `WidgetShowsArtwork`). - Libraries: AndroidX Core, Material, Coil (artwork disk cache), Kotlin coroutines, kotlinx.serialization JSON; tests add Robolectric and coroutines-test. - Feature modules never depend on other features. This module never constructs repositories; `:app`'s `NowPlayingWidgetPlaybackAdapter` and `WidgetLibrarySourceAdapter` implement the ports. @@ -69,7 +78,8 @@ src/main/res/ - `:app` calls both `configure*` helpers once from `BoxLoreApplication` after `AppContainer` is ready. On the application IO scope they check for installed widgets (`hasInstalledWidgets`) and start collection only for an installed family. - Adding a widget later starts its coordinator through the enabled broadcast. The app's adapters are lazy, so an unused family never restores playback or ranks the library. - Coordinator collection and artwork IO run on the application `CoroutineScope` from the dependencies. -- Control broadcasts use `goAsync()` with `Dispatchers.Main.immediate`: an optimistic snapshot renders first, and a failed action rolls back to the real playback state. +- Control broadcasts use `goAsync()` with `Dispatchers.Main.immediate`: an optimistic snapshot renders first from the player's last published position, the action then asks for the real position, and a failed action rolls back to the real playback state. +- Progress polling runs on the application scope only while an episode plays and a playback widget is placed; it stops when playback pauses. - `:app`'s playback adapter runs every transport action on the main thread before touching `MediaController`. - `onUpdate` reads snapshots on a background scope, then renders once. - Dependency holders expose `instance` with an `internal` setter; only `configure*` installs them. @@ -92,11 +102,13 @@ Hermetic JVM tests live in `feature/widgets/src/test`, using Robolectric and fak | Area | Tests | | :--- | :--- | -| Playback widgets | `NowPlayingWidgetRemoteViewsTest`, `NowPlayingWidgetCoordinatorTest`, `NowPlayingWidgetLogicTest`, `WidgetOptimisticActionTest` | -| Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest` | +| Playback widgets | `NowPlayingWidgetRemoteViewsTest`, `NowPlayingWidgetCoordinatorTest`, `NowPlayingWidgetLogicTest`, `WidgetOptimisticActionTest`, `WidgetActionHandlerTest` | +| Sizing, resizing and progress | `WidgetPresentationTest`, `WidgetSizesTest`, `WidgetProgressTest`, `WidgetProgressPollerTest`, `WidgetShapesTest` | +| Every widget, state and size applied and drawn | `WidgetRenderGalleryTest` (set `BOXLORE_WIDGET_RENDERS=1` to save the drawings under `build/reports/widget-renders/`) | +| Library widgets | `LibraryWidgetRemoteViewsTest`, `LibraryWidgetLogicTest`, `LibraryArtworkBatchesTest`, `ArtworkTimeoutCooldownTest` | | Snapshots and dependency holders | `NowPlayingWidgetSnapshotStoreTest`, `NowPlayingWidgetDependenciesHolderTest` | | Startup and updates | `WidgetStartupTest`, `WidgetUpdatesTest` | -| Theming and text | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest`, `WidgetTextBitmapRendererTest` | +| Theming | `WidgetChromeTest`, `WidgetRemoteViewsColorsTest` | | Intents | `WidgetActionIntentsTest` | ```bash diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt index 1682e662c..39eaa83f0 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetCoordinator.kt @@ -3,6 +3,7 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context +import cx.aswin.boxlore.feature.widgets.logic.LibraryArtworkBatches import cx.aswin.boxlore.feature.widgets.logic.LibraryWidgetLogic import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Job @@ -12,7 +13,6 @@ import kotlinx.coroutines.launch object LibraryWidgetCoordinator { private val collectJob = AtomicReference(null) - private val artworkJob = AtomicReference(null) @Volatile private var dependencies: LibraryWidgetDependencies? = null @@ -23,7 +23,6 @@ object LibraryWidgetCoordinator { if (current?.isActive == true && dependencies === deps) return dependencies = deps current?.cancel() - artworkJob.getAndSet(null)?.cancel() val context = deps.context.applicationContext val store = LibraryWidgetSnapshotStore(context) @@ -36,15 +35,15 @@ object LibraryWidgetCoordinator { renderAll(context, cached) } - combine(deps.library.subscriptions, deps.library.newEpisodes) { shows, episodes -> - shows to episodes - }.collectLatest { (shows, episodes) -> + combine(deps.library.subscriptions, deps.library.newEpisodes, deps.library.counts) { shows, episodes, counts -> + Triple(shows, episodes, counts) + }.collectLatest { (shows, episodes, counts) -> val previous = store.read() val withDiskCacheShows = shows.map { row -> row.copy( artworkCachePath = - row.artworkCachePath + row.artworkCachePath.ifOnDisk() ?: artworkLoader.resolveCachedPath(row.artworkUrl), ) } @@ -52,7 +51,7 @@ object LibraryWidgetCoordinator { episodes.map { row -> row.copy( artworkCachePath = - row.artworkCachePath + row.artworkCachePath.ifOnDisk() ?: artworkLoader.resolveCachedPath(row.artworkUrl), ) } @@ -71,6 +70,7 @@ object LibraryWidgetCoordinator { subscriptions = mergedShows, newEpisodes = mergedEpisodes, updatedAtMs = System.currentTimeMillis(), + counts = counts, ) store.write(snapshot) renderAll(context, snapshot) @@ -108,51 +108,32 @@ object LibraryWidgetCoordinator { } } - private fun loadMissingArtwork( + /** + * Fetches covers the widgets do not have yet and shows them as they arrive. It runs inside the + * collector's own block: a newer library cancels it and waits for it to stop before writing its own + * snapshot, so an older snapshot can never be saved over a newer one. + */ + private suspend fun loadMissingArtwork( context: Context, snapshot: LibraryWidgetSnapshot, artworkLoader: WidgetArtworkLoader, store: LibraryWidgetSnapshotStore, ) { - val deps = dependencies ?: return - artworkJob.getAndSet(null)?.cancel() - artworkJob.set( - deps.scope.launch { - var shows = snapshot.subscriptions - var episodes = snapshot.newEpisodes - var changed = false - - shows = - shows.map { row -> - if (!row.artworkCachePath.isNullOrBlank() || cx.aswin.boxlore.core.model.artworkUrls(row.artworkUrl, *row.fallbackArtworkUrls.toTypedArray()).isEmpty()) { - row - } else { - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@map row - changed = true - row.copy(artworkCachePath = path) - } - } - episodes = - episodes.map { row -> - if (!row.artworkCachePath.isNullOrBlank() || cx.aswin.boxlore.core.model.artworkUrls(row.artworkUrl, *row.fallbackArtworkUrls.toTypedArray()).isEmpty()) { - row - } else { - val path = artworkLoader.load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@map row - changed = true - row.copy(artworkCachePath = path) - } - } + LibraryArtworkBatches.load(snapshot, load = { url, fallbackUrls -> artworkLoader.load(url, fallbackUrls) }) { withCovers -> + store.write(withCovers.copy(updatedAtMs = System.currentTimeMillis())) + notifyRowsChanged(context.applicationContext) + } + } - if (!changed) return@launch - val withArt = - LibraryWidgetSnapshot( - subscriptions = shows, - newEpisodes = episodes, - updatedAtMs = System.currentTimeMillis(), - ) - store.write(withArt) - renderAll(context.applicationContext, withArt) - }, - ) + /** Tells the placed library widgets to redraw their rows; the frame around them stays as it is. */ + private fun notifyRowsChanged(context: Context) { + val manager = context.getSystemService(AppWidgetManager::class.java) ?: return + LibraryWidgetProviders.all.forEach { provider -> + val ids = manager.getAppWidgetIds(ComponentName(context, provider.receiverClass)) + if (ids.isNotEmpty()) manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list_view) + } } + + /** The system may clear the artwork cache, so a remembered path only counts while its file is there. */ + private fun String?.ifOnDisk(): String? = this?.takeIf { it.isNotBlank() && java.io.File(it).exists() } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt index 0e21bf310..3d0aadec2 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetDependencies.kt @@ -30,13 +30,30 @@ data class WidgetEpisodeRow( val deepLinkUri: String, ) +/** + * How many shows and latest episodes the library holds in all. The lists a widget scrolls are capped, + * so the header badge counts from here to match what the app shows. + */ +@Serializable +data class WidgetLibraryCounts( + val subscriptions: Int = 0, + val newEpisodes: Int = 0, +) + /** Combined library-widget snapshot persisted for cold updates. */ @Serializable data class LibraryWidgetSnapshot( val subscriptions: List = emptyList(), val newEpisodes: List = emptyList(), val updatedAtMs: Long = 0L, -) + val counts: WidgetLibraryCounts = WidgetLibraryCounts(), +) { + /** The badge number: the library's total, never less than the rows the widget holds. */ + fun count(kind: LibraryWidgetKind): Int = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> maxOf(counts.subscriptions, subscriptions.size) + LibraryWidgetKind.NEW_EPISODES -> maxOf(counts.newEpisodes, newEpisodes.size) + } +} enum class LibraryWidgetKind { SUBSCRIPTIONS, @@ -51,6 +68,9 @@ interface WidgetLibrarySource { val subscriptions: Flow> val newEpisodes: Flow> + + /** Totals before the lists were capped. */ + val counts: Flow } interface LibraryWidgetDependencies { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt index 26882abb4..bdb718057 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRemoteViewsService.kt @@ -3,17 +3,21 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Rect import android.net.Uri +import android.os.SystemClock import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService +import androidx.core.net.toUri +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.ArtworkTimeoutCooldown +import java.io.File +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull /** - * Supplies scrollable rows for Subscriptions / New Episodes [ListView] widgets. + * Supplies the scrolling New Episodes rows and Your Shows tiles. * Reads the latest [LibraryWidgetSnapshot] from prefs (written by the coordinator). */ class LibraryWidgetRemoteViewsService : RemoteViewsService() { @@ -49,6 +53,8 @@ internal class LibraryWidgetRemoteViewsFactory( private var shows: List = emptyList() private var episodes: List = emptyList() private var chrome: WidgetChrome = WidgetChrome.resolve(context) + private var mixedShapes: Boolean = WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.cached(context)) + private val artworkLoader by lazy { WidgetArtworkLoader(context) } override fun onCreate() = Unit @@ -57,6 +63,7 @@ internal class LibraryWidgetRemoteViewsFactory( shows = snapshot.subscriptions episodes = snapshot.newEpisodes chrome = WidgetChrome.resolve(context) + mixedShapes = WidgetShowsArtwork.usesShapes(WidgetShowsArtwork.cached(context)) } override fun onDestroy() { @@ -69,84 +76,64 @@ internal class LibraryWidgetRemoteViewsFactory( LibraryWidgetKind.NEW_EPISODES -> episodes.size } - override fun getViewAt(position: Int): RemoteViews { - val layoutId = - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.layout.library_widget_list_item - LibraryWidgetKind.NEW_EPISODES -> R.layout.library_widget_episode_list_item - } - val views = RemoteViews(context.packageName, layoutId) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_row_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - - val deepLink: String - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> { - val row = shows.getOrNull(position) ?: return views - views.setTextViewText(R.id.widget_row_title, row.title) - views.setTextViewText(R.id.widget_row_subtitle, row.subtitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_title, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_subtitle, - WidgetPalette.onSurfaceVariant, - chrome, - ) - views.setViewVisibility( - R.id.widget_row_new_dot, - if (row.isNew) View.VISIBLE else View.GONE, - ) - if (row.isNew) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_row_new_dot, - WidgetPalette.primary, - chrome, - ) - } - bindArt(views, row.artworkCachePath) - deepLink = row.deepLinkUri - } - LibraryWidgetKind.NEW_EPISODES -> { - val row = episodes.getOrNull(position) ?: return views - views.setTextViewText(R.id.widget_row_title, row.episodeTitle) - views.setTextViewText(R.id.widget_row_subtitle, row.podcastTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_title, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_row_subtitle, - WidgetPalette.onSurfaceVariant, - chrome, - ) - views.setViewVisibility(R.id.widget_row_new_dot, View.GONE) - bindArt(views, row.artworkCachePath) - deepLink = row.deepLinkUri - } + override fun getViewAt(position: Int): RemoteViews = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> showTile(position) + LibraryWidgetKind.NEW_EPISODES -> episodeRow(position) + } + + /** One show: its cover, whole or cut into this tile's shape, with a dot when it has a new episode. */ + private fun showTile(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.library_widget_list_item) + val row = shows.getOrNull(position) ?: return views + val shape = tileShape(mixedShapes, position) + val sizePx = px(TILE_ART_DP) + bindArt(views, artworkPath(row.artworkCachePath, row.artworkUrl, row.fallbackArtworkUrls), sizePx, shape) + // The placeholder is cut to the same shape, so a tile keeps its outline when it has no cover. + views.setImageViewBitmap(R.id.widget_row_art_placeholder, WidgetBitmapCache.mask(shape, sizePx)) + views.setContentDescription(R.id.widget_row_root, row.title) + + val dot = if (row.isNew) View.VISIBLE else View.GONE + views.setViewVisibility(R.id.widget_row_new_dot_ring, dot) + views.setViewVisibility(R.id.widget_row_new_dot, dot) + if (row.isNew) { + // The ring is the widget's own colour, so the dot reads as cut out of the cover. + chrome.setColorFilter(views, R.id.widget_row_new_dot_ring, LibraryWidgetRenderer.palette(kind).container) + chrome.setColorFilter(views, R.id.widget_row_new_dot, WidgetPalette.tertiary) } + views.setOnClickFillInIntent(R.id.widget_row_root, Intent().apply { data = row.deepLinkUri.toUri() }) + return views + } - val fillIn = - Intent().apply { - data = Uri.parse(deepLink) - } - views.setOnClickFillInIntent(R.id.widget_row_root, fillIn) + /** One latest episode: a pill with round artwork, the episode and its show. */ + private fun episodeRow(position: Int): RemoteViews { + val views = RemoteViews(context.packageName, R.layout.library_widget_episode_list_item) + val row = episodes.getOrNull(position) ?: return views + chrome.setColorFilter(views, R.id.widget_row_bg, WidgetPalette.surface) + views.setTextViewText(R.id.widget_row_title, row.episodeTitle) + views.setTextViewText(R.id.widget_row_subtitle, row.podcastTitle) + chrome.setTextColor(views, R.id.widget_row_title, WidgetPalette.onSurface) + chrome.setTextColor(views, R.id.widget_row_subtitle, WidgetPalette.onSurfaceVariant) + bindArt(views, artworkPath(row.artworkCachePath, row.artworkUrl, row.fallbackArtworkUrls), px(ROW_ART_DP), WidgetArtShape.CIRCLE) + views.setOnClickFillInIntent(R.id.widget_row_root, Intent().apply { data = row.deepLinkUri.toUri() }) return views } - override fun getLoadingView(): RemoteViews? = null + /** + * What a row or tile looks like while its cover is fetched: the placeholder it would show without + * one, in place of the launcher's plain "Loading" text. + */ + override fun getLoadingView(): RemoteViews = when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> + RemoteViews(context.packageName, R.layout.library_widget_list_item).also { views -> + bindArt(views, null, px(TILE_ART_DP), WidgetArtShape.ROUNDED) + views.setImageViewBitmap(R.id.widget_row_art_placeholder, WidgetBitmapCache.mask(WidgetArtShape.ROUNDED, px(TILE_ART_DP))) + } + LibraryWidgetKind.NEW_EPISODES -> + RemoteViews(context.packageName, R.layout.library_widget_episode_list_item).also { views -> + chrome.setColorFilter(views, R.id.widget_row_bg, WidgetPalette.surface) + bindArt(views, null, px(ROW_ART_DP), WidgetArtShape.CIRCLE) + } + } override fun getViewTypeCount(): Int = 1 @@ -172,43 +159,60 @@ internal class LibraryWidgetRemoteViewsFactory( private fun bindArt( views: RemoteViews, cachePath: String?, + sizePx: Int, + shape: WidgetArtShape, ) { - val bitmap = cachePath?.let { decodeArt(it) } - if (bitmap == null) { - views.setImageViewResource(R.id.widget_row_art, R.drawable.widget_art_placeholder) - } else { + val bitmap = cachePath?.let { WidgetBitmapCache.artwork(it, sizePx, shape) } + if (bitmap != null) { views.setImageViewBitmap(R.id.widget_row_art, bitmap) + views.setViewVisibility(R.id.widget_row_art, View.VISIBLE) + views.setViewVisibility(R.id.widget_row_art_placeholder, View.GONE) + views.setViewVisibility(R.id.widget_row_art_mark, View.GONE) + return } + views.setViewVisibility(R.id.widget_row_art, View.GONE) + views.setViewVisibility(R.id.widget_row_art_placeholder, View.VISIBLE) + views.setViewVisibility(R.id.widget_row_art_mark, View.VISIBLE) + chrome.setColorFilter(views, R.id.widget_row_art_placeholder, WidgetPalette.secondaryContainer) + chrome.setColorFilter(views, R.id.widget_row_art_mark, WidgetPalette.onSecondaryContainer) } - private fun decodeArt(path: String): Bitmap? { - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - val target = 80 - var sample = 1 - while (bounds.outWidth / (sample * 2) >= target && bounds.outHeight / (sample * 2) >= target) { - sample *= 2 - } - val source = - BitmapFactory.decodeFile( - path, - BitmapFactory.Options().apply { inSampleSize = sample }, - ) ?: return null - val ratio = source.width.toFloat() / source.height - val crop = - if (ratio > 1f) { - val cropWidth = source.height - val left = (source.width - cropWidth) / 2 - Rect(left, 0, left + cropWidth, source.height) - } else { - val cropHeight = source.width - val top = (source.height - cropHeight) / 2 - Rect(0, top, source.width, top + cropHeight) + /** + * Where this row's cover is on disk, fetching it first if the widget has never had it. The app only + * fetches covers while it is running; a widget placed on the home screen cannot wait for that. + * The launcher calls this off the main thread and shows the loading tile meanwhile. + */ + private fun artworkPath(cachePath: String?, url: String?, fallbackUrls: List): String? { + cachePath?.takeIf { it.isNotBlank() && File(it).exists() }?.let { return it } + if (cx.aswin.boxlore.core.model.artworkUrls(url, *fallbackUrls.toTypedArray()).isEmpty()) return null + artworkLoader.resolveCachedPath(url)?.let { return it } + if (timeouts.isCoolingDown(url, fallbackUrls)) return null + // A load that finishes, with a cover or without, or fails, has answered. Only one cut off by the + // timeout has not, and that is the one worth not waiting for again. + var answered = false + val path = + runCatching { + runBlocking { withTimeoutOrNull(ARTWORK_TIMEOUT_MS) { artworkLoader.load(url, fallbackUrls).also { answered = true } } } + }.getOrElse { + answered = true + null } - return Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888).also { out -> - Canvas(out).drawBitmap(source, crop, Rect(0, 0, target, target), null) - source.recycle() - } + if (!answered) timeouts.recordTimeout(url, fallbackUrls) + return path + } + + private fun px(dp: Int): Int = (dp * context.resources.displayMetrics.density).roundToInt().coerceIn(1, MAX_ART_PX) + + internal companion object { + /** Whole covers as rounded squares, or the mixed shapes when the listener chose them. */ + fun tileShape(mixedShapes: Boolean, position: Int): WidgetArtShape = if (mixedShapes) WidgetShapes.mixedShapeAt(position) else WidgetArtShape.ROUNDED + + private const val TILE_ART_DP = 84 + private const val ROW_ART_DP = 46 + private const val MAX_ART_PX = 288 + private const val ARTWORK_TIMEOUT_MS = 6_000L + + // Shared by both library widgets and kept across redraws, which make a new factory each time. + private val timeouts = ArtworkTimeoutCooldown(nowMs = SystemClock::elapsedRealtime) } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt index 98e9aec4a..240033392 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/LibraryWidgetRenderer.kt @@ -6,10 +6,16 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.util.TypedValue import android.view.View import android.widget.RemoteViews +import androidx.annotation.ColorRes import cx.aswin.boxlore.feature.widgets.actions.WidgetActionIntents +/** + * Draws the frame of the two library widgets: the tinted block, the count badge, the header and the + * resting state. [LibraryWidgetRemoteViewsFactory] supplies the rows and tiles. + */ object LibraryWidgetRenderer { fun updateAll( context: Context, @@ -36,15 +42,54 @@ object LibraryWidgetRenderer { } } + /** The roles one library widget is painted with. */ + internal class Palette( + @ColorRes val container: Int, + @ColorRes val onContainer: Int, + @ColorRes val accent: Int, + @ColorRes val onAccent: Int, + @ColorRes val button: Int, + @ColorRes val onButton: Int, + ) + + internal fun palette(kind: LibraryWidgetKind): Palette = when (kind) { + // New Episodes is the seafoam block with white pills; Your Shows stays neutral so covers stand out. + LibraryWidgetKind.NEW_EPISODES -> + Palette( + container = WidgetPalette.tertiaryContainer, + onContainer = WidgetPalette.onTertiaryContainer, + accent = WidgetPalette.tertiary, + onAccent = WidgetPalette.onTertiary, + button = WidgetPalette.surface, + onButton = WidgetPalette.onSurface, + ) + LibraryWidgetKind.SUBSCRIPTIONS -> + Palette( + container = WidgetPalette.surface, + onContainer = WidgetPalette.onSurface, + accent = WidgetPalette.primary, + onAccent = WidgetPalette.onPrimary, + button = WidgetPalette.primaryContainer, + onButton = WidgetPalette.onPrimaryContainer, + ) + } + fun build( context: Context, appWidgetId: Int, snapshot: LibraryWidgetSnapshot, kind: LibraryWidgetKind, ): RemoteViews { - val views = RemoteViews(context.packageName, R.layout.library_widget_list) + val layout = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.layout.library_widget_grid + LibraryWidgetKind.NEW_EPISODES -> R.layout.library_widget_list + } + val views = RemoteViews(context.packageName, layout) val chrome = WidgetChrome.resolve(context) - applyChrome(views, chrome) + val palette = palette(kind) + chrome.setColorFilter(views, R.id.widget_surface_background, palette.container) + chrome.setTextColor(views, R.id.widget_list_header, palette.onContainer) val headerRes = when (kind) { @@ -75,12 +120,11 @@ object LibraryWidgetRenderer { ), ) - val count = + val rows = when (kind) { LibraryWidgetKind.SUBSCRIPTIONS -> snapshot.subscriptions.size LibraryWidgetKind.NEW_EPISODES -> snapshot.newEpisodes.size } - views.setRemoteAdapter( R.id.widget_list_view, LibraryWidgetRemoteViewsService.adapterIntent(context, appWidgetId, kind), @@ -94,74 +138,47 @@ object LibraryWidgetRenderer { ) } - if (count == 0) { - bindEmpty( - context, - views, - chrome, - appWidgetId, - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_title - LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_title - }, - when (kind) { - LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_cta - LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_cta - }, - openListUri, - ) + if (rows == 0) { + bindEmpty(context, views, chrome, appWidgetId, kind, palette, openListUri) } else { - bindPopulated(context, views, chrome, appWidgetId, count, openListUri) + bindPopulated(context, views, chrome, appWidgetId, snapshot.count(kind), palette, openListUri) } - return views } + @Suppress("LongParameterList") private fun bindPopulated( context: Context, views: RemoteViews, chrome: WidgetChrome, appWidgetId: Int, count: Int, + palette: Palette, openListUri: String, ) { + views.setViewVisibility(R.id.widget_empty_container, View.GONE) views.setViewVisibility(R.id.widget_count_chip, View.VISIBLE) - views.setViewVisibility(R.id.widget_footer, View.VISIBLE) views.setTextViewText(R.id.widget_count_chip_text, count.toString()) - WidgetRemoteViewsColors.setTextColor( - views, + views.setTextViewTextSize( R.id.widget_count_chip_text, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_count_chip_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_footer_bg, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_footer_text, - WidgetPalette.onSecondaryContainer, - chrome, + TypedValue.COMPLEX_UNIT_SP, + if (count >= THREE_DIGITS) COUNT_SP_LONG else COUNT_SP, ) + chrome.setColorFilter(views, R.id.widget_count_chip_bg, palette.accent) + chrome.setTextColor(views, R.id.widget_count_chip_text, palette.onAccent) + + // The arrow replaces the old "View all" footer, so the whole body is content. + views.setViewVisibility(R.id.widget_open_list, View.VISIBLE) + chrome.setColorFilter(views, R.id.widget_open_list_background, palette.button) + chrome.setColorFilter(views, R.id.widget_open_list_icon, palette.onButton) val open = WidgetActionIntents.openDeepLink( context, openListUri, requestCode(appWidgetId, FOOTER_REQUEST), ) - views.setOnClickPendingIntent(R.id.widget_footer, open) - views.setOnClickPendingIntent(R.id.widget_footer_text, open) + views.setOnClickPendingIntent(R.id.widget_open_list, open) views.setOnClickPendingIntent(R.id.widget_count_chip, open) - views.setViewVisibility(R.id.widget_empty_container, View.GONE) } internal fun collectionClickTemplate( @@ -197,38 +214,43 @@ object LibraryWidgetRenderer { } } + @Suppress("LongParameterList") private fun bindEmpty( context: Context, views: RemoteViews, chrome: WidgetChrome, appWidgetId: Int, - titleRes: Int, - ctaRes: Int, + kind: LibraryWidgetKind, + palette: Palette, uri: String, ) { - views.setViewVisibility(R.id.widget_footer, View.GONE) views.setViewVisibility(R.id.widget_count_chip, View.GONE) + views.setViewVisibility(R.id.widget_open_list, View.GONE) views.setViewVisibility(R.id.widget_empty_container, View.VISIBLE) + val titleRes = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_title + LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_title + } + val ctaRes = + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> R.string.subscriptions_widget_empty_cta + LibraryWidgetKind.NEW_EPISODES -> R.string.new_episodes_widget_empty_cta + } views.setTextViewText(R.id.widget_empty_title, context.getString(titleRes)) views.setTextViewText(R.id.widget_empty_cta, context.getString(ctaRes)) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_empty_title, - WidgetPalette.onSurface, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_empty_cta, - WidgetPalette.onSecondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_empty_cta_bg, - WidgetPalette.secondaryContainer, - chrome, + views.setImageViewResource( + R.id.widget_empty_icon, + when (kind) { + LibraryWidgetKind.SUBSCRIPTIONS -> cx.aswin.boxlore.core.designsystem.R.drawable.ic_boxlore_app_mark + LibraryWidgetKind.NEW_EPISODES -> R.drawable.ic_widget_caught_up + }, ) + chrome.setColorFilter(views, R.id.widget_empty_mark_background, palette.accent) + chrome.setColorFilter(views, R.id.widget_empty_icon, palette.onAccent) + chrome.setTextColor(views, R.id.widget_empty_title, palette.onContainer) + chrome.setColorFilter(views, R.id.widget_empty_cta_bg, palette.button) + chrome.setTextColor(views, R.id.widget_empty_cta, palette.onButton) val open = WidgetActionIntents.openDeepLink( context, @@ -240,24 +262,6 @@ object LibraryWidgetRenderer { views.setOnClickPendingIntent(R.id.widget_empty_cta, open) } - private fun applyChrome( - views: RemoteViews, - chrome: WidgetChrome, - ) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_surface_background, - WidgetPalette.surface, - chrome, - ) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_list_header, - WidgetPalette.onSurface, - chrome, - ) - } - fun cancelAll(context: Context, appWidgetId: Int) { val tabFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -329,8 +333,14 @@ object LibraryWidgetRenderer { internal const val HEADER_REQUEST = 10 internal const val ROOT_REQUEST = 11 internal const val EMPTY_REQUEST = 12 + + /** The header arrow. It keeps the slot of the footer it replaced, so old pending intents still cancel. */ internal const val FOOTER_REQUEST = 13 internal const val TEMPLATE_REQUEST = 14 + private const val SUBSCRIPTIONS_TAB_URI = "boxlore://library/subscriptions?tab=0" private const val NEW_EPISODES_TAB_URI = "boxlore://library/subscriptions?tab=1" + private const val THREE_DIGITS = 100 + private const val COUNT_SP = 19f + private const val COUNT_SP_LONG = 15f } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt index ffc71f7fb..974475ee3 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinator.kt @@ -4,15 +4,21 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import cx.aswin.boxlore.feature.widgets.logic.NowPlayingWidgetMapper +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgressPoller import cx.aswin.boxlore.feature.widgets.logic.WidgetUpdatePolicy import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch object NowPlayingWidgetCoordinator { private val collectJob = AtomicReference(null) private val artworkJob = AtomicReference(null) + private val progressJob = AtomicReference(null) + + /** How often a playing widget asks the player where it is. Repaints are rarer: see [WidgetUpdatePolicy]. */ + internal const val PROGRESS_POLL_MS = 20_000L @Volatile private var artworkLoadingUrl: String? = null @@ -30,6 +36,7 @@ object NowPlayingWidgetCoordinator { artworkLoadingUrl = null val context = deps.context.applicationContext + progressJob.getAndSet(pollProgressWhilePlaying(context, deps))?.cancel() val store = NowPlayingWidgetSnapshotStore(context) val artworkLoader = WidgetArtworkLoader(context) @@ -43,11 +50,13 @@ object NowPlayingWidgetCoordinator { deps.playback.state.collectLatest { playback -> val previous = store.read() + // The system may clear the artwork cache, so a remembered path only counts while its file is there. val cachedPath = artworkLoader.resolveCachedPath(playback.artworkUrl) ?: previous ?.takeIf { it.episodeId == playback.episodeId } ?.artworkCachePath + ?.takeIf { java.io.File(it).exists() } val snapshot = NowPlayingWidgetMapper.fromPlayback( state = playback, @@ -64,6 +73,23 @@ object NowPlayingWidgetCoordinator { } } + /** + * The app stops tracking the position once it leaves the screen, which is when widgets are looked + * at. While an episode plays and a playback widget is placed, ask the player for it now and then + * so the progress ring and the time left keep moving. + */ + private fun pollProgressWhilePlaying( + context: Context, + deps: NowPlayingWidgetDependencies, + ): Job = deps.scope.launch { + WidgetProgressPoller.run( + playing = deps.playback.state.map { it.isPlaying && it.hasEpisode }, + intervalMs = PROGRESS_POLL_MS, + hasWidgets = { hasInstalledWidgets(context, WidgetProviders.all.map { it.receiverClass }) }, + refresh = { deps.playback.refreshPosition() }, + ) + } + fun requestRefresh(context: Context) { val store = NowPlayingWidgetSnapshotStore(context) val snapshot = store.read() ?: return diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt index 2bea48e08..a6791ea44 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetDependencies.kt @@ -24,6 +24,12 @@ interface WidgetPlaybackSource { suspend fun skipForward() suspend fun skipBackward() + + /** + * Publishes the player's current position through [state]. The app only tracks the position while + * it is on screen, so widgets ask for it while an episode plays in the background. + */ + suspend fun refreshPosition() } interface NowPlayingWidgetDependencies { diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt index 48848ba5a..d4c715d68 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRenderer.kt @@ -3,18 +3,26 @@ package cx.aswin.boxlore.feature.widgets import android.appwidget.AppWidgetManager import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import android.graphics.Rect -import android.os.Build -import android.text.Layout +import android.os.Bundle import android.util.SizeF -import android.util.TypedValue +import android.view.Gravity import android.view.View import android.widget.RemoteViews +import androidx.annotation.ColorRes import cx.aswin.boxlore.feature.widgets.actions.WidgetActionIntents +import cx.aswin.boxlore.feature.widgets.logic.WidgetPresentation +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgress import cx.aswin.boxlore.feature.widgets.logic.WidgetSemantics +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import cx.aswin.boxlore.feature.widgets.logic.WidgetSizes +import kotlin.math.roundToInt +/** + * Draws the four playback widgets. The layouts scale themselves to the space the launcher gives them, + * so this only decides what to show ([WidgetPresentation]), supplies the pictures and tints every part + * through [WidgetChrome]. The size a launcher reports is used for those choices and for how sharp to + * draw the pictures, never to lay anything out. + */ @Suppress("TooManyFunctions") object NowPlayingWidgetRenderer { fun updateAll( @@ -22,7 +30,7 @@ object NowPlayingWidgetRenderer { appWidgetManager: AppWidgetManager, appWidgetIds: IntArray, snapshot: NowPlayingWidgetSnapshot, - optionsById: Map = emptyMap(), + optionsById: Map = emptyMap(), variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ) { appWidgetIds.forEach { id -> @@ -36,7 +44,7 @@ object NowPlayingWidgetRenderer { appWidgetManager: AppWidgetManager, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - options: android.os.Bundle = appWidgetManager.getAppWidgetOptions(appWidgetId), + options: Bundle = appWidgetManager.getAppWidgetOptions(appWidgetId), variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ) { val remoteViews = buildRemoteViews(context, appWidgetId, snapshot, options, variant) @@ -46,528 +54,408 @@ object NowPlayingWidgetRenderer { } } + /** + * What a widget shows depends on its size, and launchers report sizes late, loosely or not at all. + * So the widget is built once for every size at which what it shows changes, and the launcher + * itself draws the one that fits the space it really has, also while the widget is being resized. + * The pictures are the same objects in each, so they are sent once. + */ internal fun buildRemoteViews( context: Context, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - options: android.os.Bundle, + options: Bundle, variant: WidgetVariant = WidgetVariant.NOW_PLAYING, ): RemoteViews { - val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 250) - val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, minWidth) - val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110) - val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, minHeight) - var widthDp = maxOf(minWidth, maxWidth) - var heightDp = maxOf(minHeight, maxHeight) - - // Prefer the widest reported size. A SizeF→RemoteViews map often left the host - // displaying a narrow title layout inside a much wider bar. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - @Suppress("DEPRECATION") - val sizes = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - options.getParcelableArrayList( - AppWidgetManager.OPTION_APPWIDGET_SIZES, - SizeF::class.java, - ) - } else { - options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) - } - val widest = sizes?.maxByOrNull { it.width } - if (widest != null) { - widthDp = widest.width.toInt().coerceAtLeast(widthDp) - heightDp = widest.height.toInt().coerceAtLeast(1) - } - } - - return buildForSize(context, appWidgetId, snapshot, variant, widthDp, heightDp) + val steps = stepsFor(variant) + val update = Update(context, appWidgetId, snapshot, variant, WidgetChrome.resolve(context), reportedSizes(options, variant).largest) + if (steps.size == 1) return buildStep(update, steps.single()) + return RemoteViews(steps.associate { step -> SizeF(step.widthDp.toFloat(), step.heightDp.toFloat()) to buildStep(update, step) }) } - private fun buildForSize( + /** The one layout a launcher shows for a widget really drawn at [drawn]: for previews and tests. */ + internal fun buildRemoteViewsAt( context: Context, appWidgetId: Int, snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, + options: Bundle, + variant: WidgetVariant = WidgetVariant.NOW_PLAYING, + drawn: WidgetSize = reportedSizes(options, variant).portrait, ): RemoteViews { - val layoutId = - when (variant) { - WidgetVariant.NOW_PLAYING -> R.layout.now_playing_widget_standard - WidgetVariant.BAR -> R.layout.now_playing_widget_bar - WidgetVariant.CONTROLS -> R.layout.playback_controls_widget - WidgetVariant.CONTROLS_NEXT -> R.layout.playback_controls_widget + val update = Update(context, appWidgetId, snapshot, variant, WidgetChrome.resolve(context), reportedSizes(options, variant).largest) + return buildStep(update, WidgetPresentation.stepFor(stepsFor(variant), drawn)) + } + + /** The sizes at which what [variant] shows changes, smallest first. The round widget never changes. */ + internal fun stepsFor(variant: WidgetVariant): List = when (variant) { + WidgetVariant.NOW_PLAYING -> WidgetPresentation.FULL_CONTROLS_STEPS + WidgetVariant.BAR -> WidgetPresentation.BAR_STEPS + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> listOf(WidgetSize(0, 0)) + } + + private fun buildStep(update: Update, shown: WidgetSize): RemoteViews { + val frame = Frame(update, shown) + return RemoteViews(update.context.packageName, layoutFor(update.variant, shown)).also { views -> + when (update.variant) { + WidgetVariant.NOW_PLAYING -> bindFullControls(frame, views) + WidgetVariant.BAR -> bindBar(frame, views) + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> bindRound(frame, views) } - return RemoteViews(context.packageName, layoutId).also { views -> - bind( - context = context, - views = views, - appWidgetId = appWidgetId, - snapshot = snapshot, - variant = variant, - widthDp = widthDp, - heightDp = heightDp, - ) } } - private fun bind( - context: Context, - views: RemoteViews, - appWidgetId: Int, - snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, + internal fun layoutFor(variant: WidgetVariant, shown: WidgetSize): Int = when (variant) { + WidgetVariant.NOW_PLAYING -> + if (WidgetPresentation.fullControls(shown).tall) { + R.layout.now_playing_widget_standard_tall + } else { + R.layout.now_playing_widget_standard + } + WidgetVariant.BAR -> R.layout.now_playing_widget_bar + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> R.layout.playback_controls_widget + } + + internal fun reportedSizes(options: Bundle, variant: WidgetVariant): WidgetSizes = WidgetSizes.of( + minWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), + maxWidthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), + minHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), + maxHeightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + fallback = + when (variant) { + WidgetVariant.NOW_PLAYING -> WidgetSize(FALLBACK_WIDE_DP, FALLBACK_TWO_ROWS_DP) + WidgetVariant.BAR -> WidgetSize(FALLBACK_WIDE_DP, FALLBACK_ONE_ROW_DP) + WidgetVariant.CONTROLS, + WidgetVariant.CONTROLS_NEXT, + -> WidgetSize(FALLBACK_TWO_ROWS_DP, FALLBACK_TWO_ROWS_DP) + }, + ) + + /** Everything one widget update needs, whatever size it is drawn at. */ + private class Update( + val context: Context, + val appWidgetId: Int, + val snapshot: NowPlayingWidgetSnapshot, + val variant: WidgetVariant, + val chrome: WidgetChrome, + /** The most space the launcher says the widget may get, so pictures stay sharp at any size. */ + val largest: WidgetSize, + ) + + /** One update drawn for one size step. */ + private class Frame( + update: Update, + /** The size what is shown is chosen for. */ + val shown: WidgetSize, ) { - views.setContentDescription(R.id.widget_root, WidgetSemantics.contentDescription(snapshot)) - configureGridCardSize(views, variant, widthDp, heightDp) - configureCompactCardHeight(views, variant) + val context: Context = update.context + val appWidgetId: Int = update.appWidgetId + val snapshot: NowPlayingWidgetSnapshot = update.snapshot + val variant: WidgetVariant = update.variant + val chrome: WidgetChrome = update.chrome + val largest: WidgetSize = update.largest + private val density: Float = context.resources.displayMetrics.density - val chrome = WidgetChrome.resolve(context) - val artwork = - snapshot.artworkCachePath - ?.takeIf(String::isNotBlank) - ?.let { path -> decodeArtwork(path, context, variant, widthDp, heightDp) } - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_surface_background, - WidgetPalette.surface, - chrome, - ) + /** Pixels for a picture about [dp] across, drawn sharp enough for the largest size reported. */ + fun px(dp: Number): Int = (dp.toFloat() * density).roundToInt().coerceIn(MIN_PICTURE_PX, MAX_PICTURE_PX) + } + + // region Now Playing · Full Controls + + private fun bindFullControls(frame: Frame, views: RemoteViews) { + val show = WidgetPresentation.fullControls(frame.shown) + bindRoot(frame, views) - if (!snapshot.hasEpisode) { - bindEmptyState(context, views, widthDp, variant, chrome) + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = true) return } + showPlaying(views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + val tallestDp = frame.largest.heightDp + bindDisc(frame, views, frame.px(if (show.tall) tallestDp * TALL_DISC_SHARE else tallestDp / 2f), DISC_ART_INSET, DISC_RING_STROKE) + bindMetadata(frame, views, show.titleLines, show.meta) - views.setViewVisibility(R.id.widget_empty_container, View.GONE) - views.setViewVisibility(R.id.widget_playing_container, View.VISIBLE) - if (artwork == null) { - views.setImageViewResource(R.id.widget_artwork, R.drawable.widget_art_placeholder) - } else { - views.setImageViewBitmap(R.id.widget_artwork, artwork) + frame.chrome.setColorFilter(views, R.id.widget_controls_start_background, WidgetPalette.surface) + frame.chrome.setColorFilter(views, R.id.widget_controls_end_background, WidgetPalette.surface) + bindPlayPause(frame, views) + val seekColors = ControlColors(WidgetPalette.primaryContainer, WidgetPalette.onPrimaryContainer) + bindControl(frame, views, Slot.LEADING, WidgetControl.SKIP_BACK, seekColors) + bindControl(frame, views, Slot.TRAILING, WidgetControl.SKIP_FORWARD, seekColors) + val edgeColors = ControlColors(background = null, icon = WidgetPalette.onSurface) + bindControl(frame, views, Slot.PREVIOUS, WidgetControl.PREVIOUS, edgeColors) + bindControl(frame, views, Slot.NEXT, WidgetControl.NEXT, edgeColors) + } + + // endregion + + // region Now Playing · Compact + + private fun bindBar(frame: Frame, views: RemoteViews) { + val show = WidgetPresentation.bar(frame.shown) + bindRoot(frame, views) + + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = show.showTimeLeft) + return } + showPlaying(views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + bindDisc(frame, views, frame.px(frame.largest.heightDp.coerceAtMost(BAR_MAX_DISC_DP)), DISC_ART_INSET, DISC_RING_STROKE) + // The artwork already says which show this is, so the narrow title area keeps the episode name. + bindMetadata(frame, views, show.titleLines, if (show.showTimeLeft) WidgetPresentation.Meta.TIME else WidgetPresentation.Meta.NONE) + + bindPlayPause(frame, views) + val miniColors = ControlColors(WidgetPalette.surface, WidgetPalette.onSurface) + bindControl(frame, views, Slot.LEADING, WidgetControl.SKIP_BACK, miniColors) + bindControl(frame, views, Slot.TRAILING, WidgetControl.SKIP_FORWARD, miniColors) + } + + // endregion - if (!variant.isControlsGrid()) { - bindMetadata(views, snapshot, chrome) + // region Now Playing · Round + + private fun bindRound(frame: Frame, views: RemoteViews) { + bindRoot(frame, views) + frame.chrome.setColorFilter(views, R.id.widget_surface_background, WidgetPalette.primaryContainer) + if (!frame.snapshot.hasEpisode) { + bindRest(frame, views, showSubtitle = false) + return } - bindTransport(context, views, appWidgetId, snapshot, variant, chrome) - views.setOnClickPendingIntent(R.id.widget_root, WidgetActionIntents.openApp(context)) + showPlaying(views) + bindDisc(frame, views, frame.px(frame.largest.shortSide), ROUND_ART_INSET, ROUND_RING_STROKE) + bindPlayPause(frame, views) + val miniColors = ControlColors(WidgetPalette.surface, WidgetPalette.onSurface) + // Seek has back and forward. Next keeps forward on the left and puts next episode on the right. + val next = frame.variant == WidgetVariant.CONTROLS_NEXT + bindControl(frame, views, Slot.LEADING, if (next) WidgetControl.SKIP_FORWARD else WidgetControl.SKIP_BACK, miniColors) + bindControl(frame, views, Slot.TRAILING, if (next) WidgetControl.NEXT else WidgetControl.SKIP_FORWARD, miniColors) } - private fun bindEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - variant: WidgetVariant, - chrome: WidgetChrome, - ) { + // endregion + + // region Shared parts + + private fun bindRoot(frame: Frame, views: RemoteViews) { + views.setContentDescription(R.id.widget_root, WidgetSemantics.contentDescription(frame.snapshot)) + views.setOnClickPendingIntent(R.id.widget_root, WidgetActionIntents.openApp(frame.context)) + } + + private fun showPlaying(views: RemoteViews) { + views.setViewVisibility(R.id.widget_empty_container, View.GONE) + views.setViewVisibility(R.id.widget_playing_container, View.VISIBLE) + } + + /** Nothing playing: the scalloped mark and a line that opens boxlore. */ + private fun bindRest(frame: Frame, views: RemoteViews, showSubtitle: Boolean) { views.setViewVisibility(R.id.widget_playing_container, View.GONE) views.setViewVisibility(R.id.widget_empty_container, View.VISIBLE) - if (!variant.isControlsGrid()) { - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_empty_surface_background, - WidgetPalette.surface, - chrome, + // The round widget rests on its own circle; the others have a pill of their own. + if (frame.variant == WidgetVariant.NOW_PLAYING || frame.variant == WidgetVariant.BAR) { + frame.chrome.setColorFilter(views, R.id.widget_empty_surface_background, WidgetPalette.primaryContainer) + views.setViewVisibility(R.id.widget_empty_subtitle, if (showSubtitle) View.VISIBLE else View.GONE) + views.setTextViewText(R.id.widget_empty_subtitle, frame.context.getString(R.string.widget_empty_subtitle)) + frame.chrome.setTextColor(views, R.id.widget_empty_subtitle, WidgetPalette.onPrimaryContainer) + views.setInt( + R.id.widget_empty_title, + METHOD_GRAVITY, + if (showSubtitle) Gravity.BOTTOM or Gravity.START else Gravity.CENTER_VERTICAL or Gravity.START, ) } - val appIcon = context.applicationInfo.icon - if (appIcon != 0) { - views.setImageViewResource(R.id.widget_empty_icon, appIcon) - } - views.setOnClickPendingIntent(R.id.widget_empty_container, WidgetActionIntents.openApp(context)) + frame.chrome.setColorFilter(views, R.id.widget_empty_mark_background, WidgetPalette.primary) + frame.chrome.setColorFilter(views, R.id.widget_empty_icon, WidgetPalette.onPrimary) + views.setTextViewText(R.id.widget_empty_title, frame.context.getString(R.string.widget_empty_title)) + frame.chrome.setTextColor(views, R.id.widget_empty_title, WidgetPalette.onPrimaryContainer) + views.setOnClickPendingIntent(R.id.widget_empty_container, WidgetActionIntents.openApp(frame.context)) + } - when (variant) { - WidgetVariant.NOW_PLAYING -> bindStandardEmptyState(context, views, widthDp, chrome) - WidgetVariant.BAR -> bindBarEmptyState(context, views, widthDp, chrome) - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> bindControlsEmptyState(context, views, widthDp, chrome) + /** Round artwork inside the progress ring: what has played, a gap, then what is left. */ + private fun bindDisc(frame: Frame, views: RemoteViews, discPx: Int, artInset: Float, ringStroke: Float) { + val snapshot = frame.snapshot + val ring = WidgetBitmapCache.progressRing(discPx, WidgetProgress.ringStep(snapshot.positionMs, snapshot.durationMs), ringStroke) + views.setMaskOrHide(R.id.widget_progress, ring.played) + frame.chrome.setColorFilter(views, R.id.widget_progress, WidgetPalette.primary) + views.setMaskOrHide(R.id.widget_progress_track, ring.remaining) + frame.chrome.setColorFilter(views, R.id.widget_progress_track, WidgetPalette.onPrimaryContainer) + views.setInt(R.id.widget_progress_track, "setImageAlpha", TRACK_ALPHA) + + val artwork = + snapshot.artworkCachePath + ?.takeIf(String::isNotBlank) + ?.let { path -> WidgetBitmapCache.artwork(path, discPx, WidgetArtShape.CIRCLE, artInset) } + if (artwork != null) { + views.setImageViewBitmap(R.id.widget_artwork, artwork) + views.setViewVisibility(R.id.widget_artwork, View.VISIBLE) + views.setViewVisibility(R.id.widget_artwork_placeholder, View.GONE) + views.setViewVisibility(R.id.widget_artwork_mark, View.GONE) + return } + // No artwork yet: a tinted circle with the boxlore mark, in the same place. + views.setViewVisibility(R.id.widget_artwork, View.GONE) + views.setImageViewBitmap(R.id.widget_artwork_placeholder, WidgetBitmapCache.mask(WidgetArtShape.CIRCLE, discPx, artInset)) + views.setViewVisibility(R.id.widget_artwork_placeholder, View.VISIBLE) + frame.chrome.setColorFilter(views, R.id.widget_artwork_placeholder, WidgetPalette.secondaryContainer) + views.setViewVisibility(R.id.widget_artwork_mark, View.VISIBLE) + frame.chrome.setColorFilter(views, R.id.widget_artwork_mark, WidgetPalette.onSecondaryContainer) } - private fun bindStandardEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val metadataWidth = (widthDp - STANDARD_EMPTY_METADATA_INSETS_DP).coerceAtLeast(MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_title), - widthDp = metadataWidth, - heightDp = 40, - preferredSizeSp = 17f, - minSizeSp = 13f, - weight = TITLE_WEIGHT, - maxLines = 2, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) - views.setImageViewBitmap( - R.id.widget_empty_subtitle, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_subtitle), - widthDp = metadataWidth, - heightDp = 18, - preferredSizeSp = 11f, - minSizeSp = 9f, - weight = BODY_WEIGHT, - maxLines = 1, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurfaceVariant), - ), + /** + * The episode title, then whatever there is room for under it. The title always comes first: when + * the line under it goes, the title is centred in the space instead. + */ + private fun bindMetadata(frame: Frame, views: RemoteViews, titleLines: Int, meta: WidgetPresentation.Meta) { + val line = metaLine(frame.context, frame.snapshot, meta) + views.setTextViewText(R.id.widget_episode_title, frame.snapshot.episodeTitle) + views.setInt(R.id.widget_episode_title, "setMaxLines", titleLines) + views.setInt( + R.id.widget_episode_title, + METHOD_GRAVITY, + if (line.isEmpty()) Gravity.CENTER_VERTICAL or Gravity.START else Gravity.BOTTOM or Gravity.START, ) + frame.chrome.setTextColor(views, R.id.widget_episode_title, WidgetPalette.onPrimaryContainer) + views.setViewVisibility(R.id.widget_podcast_title, if (line.isEmpty()) View.GONE else View.VISIBLE) + views.setTextViewText(R.id.widget_podcast_title, line) + frame.chrome.setTextColor(views, R.id.widget_podcast_title, WidgetPalette.onPrimaryContainer) } - private fun bindBarEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val textWidth = (widthDp - BAR_EMPTY_CONTENT_INSETS_DP).coerceAtLeast(MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_bar_title), - widthDp = textWidth, - heightDp = 24, - preferredSizeSp = 14f, - minSizeSp = 11f, - weight = TITLE_WEIGHT, - maxLines = 1, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) + /** + * "36m left · The Daily Lore": the time first, so a long show name is what gets cut off. With no + * time to show yet, the show name stands alone wherever there is room for it. + */ + internal fun metaLine(context: Context, snapshot: NowPlayingWidgetSnapshot, meta: WidgetPresentation.Meta): String { + val timeLeft = timeLeftLabel(context, snapshot) + val show = snapshot.podcastTitle + return when (meta) { + WidgetPresentation.Meta.NONE -> "" + WidgetPresentation.Meta.TIME -> timeLeft.orEmpty() + WidgetPresentation.Meta.TIME_AND_SHOW -> + when { + timeLeft == null -> show + show.isBlank() -> timeLeft + else -> context.getString(R.string.widget_meta_time_and_show, timeLeft, show) + } + } } - private fun bindControlsEmptyState( - context: Context, - views: RemoteViews, - widthDp: Int, - chrome: WidgetChrome, - ) { - val textWidth = - (widthDp - GRID_EMPTY_HORIZONTAL_PADDING_DP) - .coerceAtLeast(GRID_MIN_TEXT_WIDTH_DP) - views.setImageViewBitmap( - R.id.widget_empty_title, - WidgetTextBitmapRenderer.renderColor( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = context.getString(R.string.widget_empty_title), - widthDp = textWidth, - heightDp = 28, - preferredSizeSp = 11f, - minSizeSp = 9f, - weight = TITLE_WEIGHT, - maxLines = 2, - alignment = Layout.Alignment.ALIGN_CENTER, - ), - color = chrome.argb(WidgetPalette.onSurface), - ), - ) + internal fun timeLeftLabel(context: Context, snapshot: NowPlayingWidgetSnapshot): String? { + val left = WidgetProgress.timeLeft(snapshot.positionMs, snapshot.durationMs) ?: return null + return when { + left.hours == 0 -> context.getString(R.string.widget_time_left_minutes, left.minutes) + left.minutes == 0 -> context.getString(R.string.widget_time_left_hours, left.hours) + else -> context.getString(R.string.widget_time_left_hours_minutes, left.hours, left.minutes) + } } - private fun bindMetadata( - views: RemoteViews, - snapshot: NowPlayingWidgetSnapshot, - chrome: WidgetChrome, - ) { - views.setTextViewText(R.id.widget_episode_title, snapshot.episodeTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_episode_title, - WidgetPalette.onSurface, - chrome, - ) - views.setTextViewText(R.id.widget_podcast_title, snapshot.podcastTitle) - WidgetRemoteViewsColors.setTextColor( - views, - R.id.widget_podcast_title, - WidgetPalette.onSurfaceVariant, - chrome, + private fun bindPlayPause(frame: Frame, views: RemoteViews) { + bindPlayPauseState(frame.context, views, frame.chrome, frame.snapshot.isPlaying) + views.setOnClickPendingIntent( + R.id.widget_play_pause, + WidgetActionIntents.broadcast(frame.context, frame.appWidgetId, WidgetControl.TOGGLE), ) } - private fun bindTransport( - context: Context, - views: RemoteViews, - appWidgetId: Int, - snapshot: NowPlayingWidgetSnapshot, - variant: WidgetVariant, - chrome: WidgetChrome, - ) { - val playBackground = - if (variant.isControlsGrid() || snapshot.isPlaying) { - R.drawable.widget_button_pause - } else { - R.drawable.widget_button_play - } - views.setImageViewResource(R.id.widget_play_pause_background, playBackground) - WidgetRemoteViewsColors.setColorFilter( - views, + /** The play button: a circle at rest, a scalloped cookie while playing. */ + private fun bindPlayPauseState(context: Context, views: RemoteViews, chrome: WidgetChrome, playing: Boolean) { + views.setImageViewResource( R.id.widget_play_pause_background, - WidgetPalette.primary, - chrome, + if (playing) R.drawable.widget_cookie else R.drawable.widget_circle, ) + chrome.setColorFilter(views, R.id.widget_play_pause_background, WidgetPalette.primary) views.setImageViewResource( R.id.widget_play_pause_icon, - if (snapshot.isPlaying) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - R.id.widget_play_pause_icon, - WidgetPalette.onPrimary, - chrome, + if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play, ) + chrome.setColorFilter(views, R.id.widget_play_pause_icon, WidgetPalette.onPrimary) views.setContentDescription( R.id.widget_play_pause_icon, - context.getString(if (snapshot.isPlaying) R.string.widget_pause else R.string.widget_play), - ) - views.setOnClickPendingIntent( - R.id.widget_play_pause, - WidgetActionIntents.broadcast(context, appWidgetId, WidgetControl.TOGGLE), - ) - - val leadingControl = - if (variant == WidgetVariant.CONTROLS_NEXT) { - WidgetControl.NEXT - } else { - WidgetControl.SKIP_BACK - } - views.setImageViewResource( - R.id.widget_skip_back_icon, - if (leadingControl == WidgetControl.NEXT) { - R.drawable.ic_widget_next - } else { - R.drawable.ic_widget_skip_back - }, - ) - views.setContentDescription( - R.id.widget_skip_back_icon, - context.getString( - if (leadingControl == WidgetControl.NEXT) { - R.string.widget_next - } else { - R.string.widget_skip_back - }, - ), - ) - - // Compact surfaces use seek-back by default; the alternate grid uses next. - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_skip_back, - backgroundId = R.id.widget_skip_back_background, - iconId = R.id.widget_skip_back_icon, - control = leadingControl, - chrome = chrome, + context.getString(if (playing) R.string.widget_pause else R.string.widget_play), ) - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_skip_forward, - backgroundId = R.id.widget_skip_forward_background, - iconId = R.id.widget_skip_forward_icon, - control = WidgetControl.SKIP_FORWARD, - chrome = chrome, - ) - - if (variant == WidgetVariant.NOW_PLAYING) { - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_previous, - backgroundId = R.id.widget_previous_background, - iconId = R.id.widget_previous_icon, - control = WidgetControl.PREVIOUS, - chrome = chrome, - ) - bindSecondaryButton( - context = context, - views = views, - appWidgetId = appWidgetId, - containerId = R.id.widget_next, - backgroundId = R.id.widget_next_background, - iconId = R.id.widget_next_icon, - control = WidgetControl.NEXT, - chrome = chrome, - ) - } } - private fun bindSecondaryButton( - context: Context, - views: RemoteViews, - appWidgetId: Int, - containerId: Int, - backgroundId: Int, - iconId: Int, - control: WidgetControl, - chrome: WidgetChrome, - ) { - WidgetRemoteViewsColors.setColorFilter( - views, - backgroundId, - WidgetPalette.secondaryContainer, - chrome, - ) - WidgetRemoteViewsColors.setColorFilter( - views, - iconId, - WidgetPalette.onSecondaryContainer, - chrome, - ) - views.setOnClickPendingIntent( - containerId, - WidgetActionIntents.broadcast(context, appWidgetId, control), - ) + /** The views of one control. Leading and trailing hold whichever control the widget puts there. */ + private enum class Slot(val container: Int, val background: Int?, val icon: Int) { + LEADING(R.id.widget_skip_back, R.id.widget_skip_back_background, R.id.widget_skip_back_icon), + TRAILING(R.id.widget_skip_forward, R.id.widget_skip_forward_background, R.id.widget_skip_forward_icon), + PREVIOUS(R.id.widget_previous, null, R.id.widget_previous_icon), + NEXT(R.id.widget_next, null, R.id.widget_next_icon), } - private fun configureGridCardSize( - views: RemoteViews, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, - ) { - if (!variant.isControlsGrid()) return - val sideDp = minOf(widthDp, heightDp).coerceAtLeast(GRID_MIN_SIDE_DP) - views.setViewLayoutWidth(R.id.widget_grid_card, sideDp.toFloat(), TypedValue.COMPLEX_UNIT_DIP) - views.setViewLayoutHeight(R.id.widget_grid_card, sideDp.toFloat(), TypedValue.COMPLEX_UNIT_DIP) - setSquareSize(views, R.id.widget_skip_back_icon, sideDp * GRID_CONTROL_ICON_RATIO) - setSquareSize(views, R.id.widget_skip_forward_icon, sideDp * GRID_CONTROL_ICON_RATIO) - setSquareSize(views, R.id.widget_play_pause_icon, sideDp * GRID_PLAY_ICON_RATIO) - } + private class ControlColors(@ColorRes val background: Int?, @ColorRes val icon: Int) - /** - * Pin the beige card to content height so launcher hosts cannot stretch a - * match_parent surface into tall empty bands inside a 2-row cell. - */ - private fun configureCompactCardHeight( + private fun bindControl( + frame: Frame, views: RemoteViews, - variant: WidgetVariant, + slot: Slot, + control: WidgetControl, + colors: ControlColors, ) { - val heightDp = - when (variant) { - // 48 metadata + 4 gap + 38 controls + 10 vertical padding - WidgetVariant.NOW_PLAYING -> STANDARD_CARD_HEIGHT_DP - // 40 art + 8 vertical padding - WidgetVariant.BAR -> BAR_CARD_HEIGHT_DP - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> return + val context = frame.context + if (slot.background != null && colors.background != null) { + frame.chrome.setColorFilter(views, slot.background, colors.background) + } + val description: Int + when (control) { + WidgetControl.SKIP_BACK -> { + views.setImageViewBitmap(slot.icon, WidgetBitmapCache.seekIcon(context, seconds(frame.snapshot.seekBackwardMs), forward = false)) + description = R.string.widget_skip_back } - views.setViewLayoutHeight( - R.id.widget_playing_container, - heightDp.toFloat(), - TypedValue.COMPLEX_UNIT_DIP, + WidgetControl.SKIP_FORWARD -> { + views.setImageViewBitmap(slot.icon, WidgetBitmapCache.seekIcon(context, seconds(frame.snapshot.seekForwardMs), forward = true)) + description = R.string.widget_skip_forward + } + WidgetControl.NEXT -> { + views.setImageViewResource(slot.icon, R.drawable.ic_widget_next) + description = R.string.widget_next + } + WidgetControl.PREVIOUS -> { + views.setImageViewResource(slot.icon, R.drawable.ic_widget_previous) + description = R.string.widget_previous + } + WidgetControl.TOGGLE, + WidgetControl.OPEN_APP, + -> return + } + frame.chrome.setColorFilter(views, slot.icon, colors.icon) + views.setContentDescription(slot.icon, context.getString(description)) + views.setOnClickPendingIntent( + slot.container, + WidgetActionIntents.broadcast(context, frame.appWidgetId, control), ) } - private fun setSquareSize( - views: RemoteViews, - viewId: Int, - sizeDp: Float, - ) { - views.setViewLayoutWidth(viewId, sizeDp, TypedValue.COMPLEX_UNIT_DIP) - views.setViewLayoutHeight(viewId, sizeDp, TypedValue.COMPLEX_UNIT_DIP) - } + private fun seconds(ms: Long): Int = (ms / MS_PER_SECOND).toInt().coerceAtLeast(1) - private fun decodeArtwork( - path: String, - context: Context, - variant: WidgetVariant, - widthDp: Int, - heightDp: Int, - ): Bitmap? { - val density = context.resources.displayMetrics.density - val targetSizeDp = - when (variant) { - WidgetVariant.NOW_PLAYING -> 48 - WidgetVariant.BAR -> 40 - WidgetVariant.CONTROLS, - WidgetVariant.CONTROLS_NEXT, - -> - ((minOf(widthDp, heightDp) - GRID_INSETS_DP) / 2) - .coerceAtLeast(GRID_MIN_ART_DP) - } - val targetSizePx = (targetSizeDp * density).toInt().coerceAtLeast(1) - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(path, bounds) - if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null - - var sampleSize = 1 - while (bounds.outWidth / (sampleSize * 2) >= targetSizePx && - bounds.outHeight / (sampleSize * 2) >= targetSizePx - ) { - sampleSize *= 2 - } - val source = - BitmapFactory.decodeFile( - path, - BitmapFactory.Options().apply { inSampleSize = sampleSize }, - ) ?: return null - val sourceRatio = source.width.toFloat() / source.height - val crop = - if (sourceRatio > 1f) { - val cropWidth = source.height - val left = (source.width - cropWidth) / 2 - Rect(left, 0, left + cropWidth, source.height) - } else { - val cropHeight = source.width - val top = (source.height - cropHeight) / 2 - Rect(0, top, source.width, top + cropHeight) - } - return Bitmap.createBitmap(targetSizePx, targetSizePx, Bitmap.Config.ARGB_8888).also { output -> - Canvas(output).drawBitmap( - source, - crop, - Rect(0, 0, targetSizePx, targetSizePx), - null, - ) - source.recycle() + // endregion + + private fun RemoteViews.setMaskOrHide(viewId: Int, mask: Bitmap?) { + if (mask == null) { + setViewVisibility(viewId, View.GONE) + } else { + setImageViewBitmap(viewId, mask) + setViewVisibility(viewId, View.VISIBLE) } } - private const val TITLE_WEIGHT = 600 - private const val BODY_WEIGHT = 400 - private const val MIN_TEXT_WIDTH_DP = 72 - private const val GRID_MIN_TEXT_WIDTH_DP = 48 - private const val STANDARD_EMPTY_METADATA_INSETS_DP = 104 - private const val BAR_EMPTY_CONTENT_INSETS_DP = 70 - private const val GRID_EMPTY_HORIZONTAL_PADDING_DP = 20 - private const val GRID_MIN_SIDE_DP = 110 - private const val GRID_INSETS_DP = 38 - private const val GRID_MIN_ART_DP = 36 - private const val GRID_CONTROL_ICON_RATIO = 0.14f - private const val GRID_PLAY_ICON_RATIO = 0.16f - private const val STANDARD_CARD_HEIGHT_DP = 100 - private const val BAR_CARD_HEIGHT_DP = 48 - - private fun WidgetVariant.isControlsGrid(): Boolean = this == WidgetVariant.CONTROLS || this == WidgetVariant.CONTROLS_NEXT + private const val FALLBACK_WIDE_DP = 250 + private const val FALLBACK_TWO_ROWS_DP = 110 + private const val FALLBACK_ONE_ROW_DP = 48 + + private const val METHOD_GRAVITY = "setGravity" + private const val MIN_PICTURE_PX = 96 + private const val MAX_PICTURE_PX = 512 + private const val MS_PER_SECOND = 1000L + private const val TRACK_ALPHA = 56 + private const val BAR_MAX_DISC_DP = 110 + private const val TALL_DISC_SHARE = 0.66f + + /** The artwork sits inside the ring: 8dp clear on a 78dp disc, 10dp on the 170dp round widget. */ + private const val DISC_ART_INSET = 0.105f + private const val DISC_RING_STROKE = 3.4f / 78f + private const val ROUND_ART_INSET = 10f / 170f + private const val ROUND_RING_STROKE = 5f / 170f } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt new file mode 100644 index 000000000..618555d30 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetBitmapCache.kt @@ -0,0 +1,63 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.util.LruCache +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import cx.aswin.boxlore.feature.widgets.logic.WidgetProgress +import java.io.File + +/** + * Keeps the pictures a widget is drawn from, so a redraw after a tap or a progress step reuses them + * instead of decoding the cover and drawing every shape again. That is what makes a widget answer a + * tap at once. Entries are keyed by everything that changes how they look, and the oldest are dropped + * once the cache is full. + */ +internal object WidgetBitmapCache { + private val bitmaps = + object : LruCache(MAX_BYTES) { + override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount + } + + /** The cover at [path] cut to [shape], or null when the file cannot be read. */ + fun artwork(path: String, sizePx: Int, shape: WidgetArtShape, insetFraction: Float = 0f): Bitmap? { + val file = File(path) + if (!file.exists()) return null + // A cover that is replaced on disk keeps its path, so its change time is part of the key. + val key = "art|$path|${file.lastModified()}|$sizePx|$shape|$insetFraction" + bitmaps.get(key)?.let { return it } + val source = WidgetShapes.decodeSampled(path, sizePx) ?: return null + return WidgetShapes.shapeArtwork(source, sizePx, shape, insetFraction).also { + bitmaps.put(key, it) + source.recycle() + } + } + + fun mask(shape: WidgetArtShape, sizePx: Int, insetFraction: Float = 0f): Bitmap = cached("mask|$sizePx|$shape|$insetFraction") { WidgetShapes.mask(shape, sizePx, insetFraction) } + + /** The ring at one of its [WidgetProgress.RING_STEPS] steps. Either arc is null when it has nothing to draw. */ + fun progressRing(sizePx: Int, step: Int, strokeFraction: Float): WidgetShapes.ProgressRing { + val key = "ring|$sizePx|$step|$strokeFraction" + val played = bitmaps.get("$key|played") + val remaining = bitmaps.get("$key|remaining") + // A step with only one arc is cheap to draw again, so only complete pairs are served from here. + if (played != null && remaining != null) return WidgetShapes.ProgressRing(played, remaining) + return WidgetShapes.progressRing(sizePx, step.toFloat() / WidgetProgress.RING_STEPS, strokeFraction).also { ring -> + ring.played?.let { bitmaps.put("$key|played", it) } + ring.remaining?.let { bitmaps.put("$key|remaining", it) } + } + } + + fun seekIcon(context: Context, seconds: Int, forward: Boolean): Bitmap { + // The number is drawn in the listener's lettering, so a change of lettering draws it again. + val roundness = GoogleSansTypefaces.cachedRoundness(context) + return cached("seek|$seconds|$forward|$roundness") { WidgetSeekIcon.mask(context, seconds, forward, SEEK_ICON_PX) } + } + + private inline fun cached(key: String, draw: () -> Bitmap): Bitmap = bitmaps.get(key) ?: draw().also { bitmaps.put(key, it) } + + private const val MAX_BYTES = 12 * 1024 * 1024 + + /** Sharp enough for the largest seek button; the layout scales it down. */ + private const val SEEK_ICON_PX = 120 +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt index 945280881..2e373b4ad 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetChrome.kt @@ -14,65 +14,75 @@ import cx.aswin.boxlore.core.prefs.WidgetAppearance /** * Resolves widget Material roles from either the in-app Appearance theme (default) * or system Material You resources. + * + * Every colour reaches the launcher as a tint, never baked into a bitmap, in one of three forms: + * - System appearance: a colour resource, which the launcher resolves with its own light/dark mode + * and wallpaper palette. + * - App theme with a fixed Light or Dark display mode: one ARGB value. + * - App theme whose display mode follows the system: a light and a dark ARGB value, so the launcher + * switches with the phone even while the app's process is not running. */ internal class WidgetChrome private constructor( private val context: Context, private val followAppTheme: Boolean, private val appColors: BoxLoreChromeColors?, + private val dayNight: DayNight?, ) { + /** The app theme's roles for the phone's light and dark modes. */ + internal class DayNight(val day: BoxLoreChromeColors, val night: BoxLoreChromeColors) + val usesAppTheme: Boolean = followAppTheme + /** True when the launcher, not this process, picks between the light and dark app colours. */ + val followsSystemDayNight: Boolean = dayNight != null + fun setColorFilter( views: RemoteViews, viewId: Int, @ColorRes colorRes: Int, - ) { - if (followAppTheme) { - views.setInt(viewId, "setColorFilter", argb(colorRes)) - } else { - views.setColor(viewId, "setColorFilter", colorRes) - } - } + ) = applyColor(views, viewId, METHOD_COLOR_FILTER, colorRes) fun setTextColor( views: RemoteViews, viewId: Int, @ColorRes colorRes: Int, + ) = applyColor(views, viewId, METHOD_TEXT_COLOR, colorRes) + + private fun applyColor( + views: RemoteViews, + viewId: Int, + method: String, + @ColorRes colorRes: Int, ) { - if (followAppTheme) { - views.setTextColor(viewId, argb(colorRes)) - } else { - views.setColor(viewId, "setTextColor", colorRes) + val pair = dayNight + when { + !followAppTheme -> views.setColor(viewId, method, colorRes) + pair != null -> + views.setColorInt( + viewId, + method, + pair.day.role(colorRes) ?: argb(colorRes), + pair.night.role(colorRes) ?: argb(colorRes), + ) + else -> views.setInt(viewId, method, argb(colorRes)) } } @ColorInt fun argb( @ColorRes colorRes: Int, - ): Int { - val colors = appColors - if (colors != null) { - return when (colorRes) { - R.color.widget_surface -> colors.surface - R.color.widget_on_surface -> colors.onSurface - R.color.widget_on_surface_variant -> colors.onSurfaceVariant - R.color.widget_primary -> colors.primary - R.color.widget_on_primary -> colors.onPrimary - R.color.widget_primary_container -> colors.primaryContainer - R.color.widget_on_primary_container -> colors.onPrimaryContainer - R.color.widget_secondary_container -> colors.secondaryContainer - R.color.widget_on_secondary_container -> colors.onSecondaryContainer - else -> ContextCompat.getColor(context, colorRes) - } - } - return ContextCompat.getColor(context, colorRes) - } + ): Int = appColors?.role(colorRes) ?: ContextCompat.getColor(context, colorRes) companion object { + private const val METHOD_COLOR_FILTER = "setColorFilter" + private const val METHOD_TEXT_COLOR = "setTextColor" + private const val THEME_CONFIG_LIGHT = "light" + private const val THEME_CONFIG_DARK = "dark" + fun resolve(context: Context): WidgetChrome { val app = context.applicationContext if (!WidgetAppearance.followsAppTheme(WidgetAppearance.cached(app))) { - return WidgetChrome(app, followAppTheme = false, appColors = null) + return WidgetChrome(app, followAppTheme = false, appColors = null, dayNight = null) } val prefs = app.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) @@ -80,15 +90,45 @@ internal class WidgetChrome private constructor( val surfaceStyle = prefs.getString("surface_style", "classic_dynamic") ?: "classic_dynamic" val themeBrand = prefs.getString("theme_brand", "violet") ?: "violet" val dynamicColor = prefs.getBoolean("use_dynamic_color", false) - val colors = - resolveBoxLoreChromeColors( - context = app, - darkTheme = darkThemeFromConfig(app, themeConfig), - dynamicColor = dynamicColor, - themeBrand = themeBrand, - surfaceStyle = surfaceStyle, - ) - return WidgetChrome(app, followAppTheme = true, appColors = colors) + + fun colors(dark: Boolean): BoxLoreChromeColors = resolveBoxLoreChromeColors( + context = app, + darkTheme = dark, + dynamicColor = dynamicColor, + themeBrand = themeBrand, + surfaceStyle = surfaceStyle, + ) + + val followsSystem = themeConfig != THEME_CONFIG_LIGHT && themeConfig != THEME_CONFIG_DARK + return WidgetChrome( + context = app, + followAppTheme = true, + appColors = colors(darkThemeFromConfig(app, themeConfig)), + dayNight = if (followsSystem) DayNight(day = colors(false), night = colors(true)) else null, + ) } } } + +/** Each widget colour resource and the app-theme role that stands in for it. */ +private val APP_THEME_ROLES: Map Int> = + mapOf( + R.color.widget_surface to BoxLoreChromeColors::surface, + R.color.widget_on_surface to BoxLoreChromeColors::onSurface, + R.color.widget_on_surface_variant to BoxLoreChromeColors::onSurfaceVariant, + R.color.widget_primary to BoxLoreChromeColors::primary, + R.color.widget_on_primary to BoxLoreChromeColors::onPrimary, + R.color.widget_primary_container to BoxLoreChromeColors::primaryContainer, + R.color.widget_on_primary_container to BoxLoreChromeColors::onPrimaryContainer, + R.color.widget_secondary_container to BoxLoreChromeColors::secondaryContainer, + R.color.widget_on_secondary_container to BoxLoreChromeColors::onSecondaryContainer, + R.color.widget_tertiary to BoxLoreChromeColors::tertiary, + R.color.widget_on_tertiary to BoxLoreChromeColors::onTertiary, + R.color.widget_tertiary_container to BoxLoreChromeColors::tertiaryContainer, + R.color.widget_on_tertiary_container to BoxLoreChromeColors::onTertiaryContainer, + ) + +@ColorInt +private fun BoxLoreChromeColors.role( + @ColorRes colorRes: Int, +): Int? = APP_THEME_ROLES[colorRes]?.invoke(this) diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt index 098ac95ea..d60c49f97 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetPalette.kt @@ -24,4 +24,12 @@ internal object WidgetPalette { @ColorRes val primaryContainer: Int = R.color.widget_primary_container @ColorRes val onPrimaryContainer: Int = R.color.widget_on_primary_container + + @ColorRes val tertiary: Int = R.color.widget_tertiary + + @ColorRes val onTertiary: Int = R.color.widget_on_tertiary + + @ColorRes val tertiaryContainer: Int = R.color.widget_tertiary_container + + @ColorRes val onTertiaryContainer: Int = R.color.widget_on_tertiary_container } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt new file mode 100644 index 000000000..3355ab68c --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetSeekIcon.kt @@ -0,0 +1,87 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import androidx.core.graphics.createBitmap +import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces +import kotlin.math.cos +import kotlin.math.sin + +/** + * The seek icon the player uses: a circular arrow around the number of seconds it jumps. Drawn as a + * white mask so the launcher tints it, and redrawn when the listener changes the seek length. + */ +internal object WidgetSeekIcon { + fun mask(context: Context, seconds: Int, forward: Boolean, sizePx: Int): Bitmap { + val size = sizePx.coerceAtLeast(1).toFloat() + val stroke = size * STROKE + val inset = stroke * INSET_STROKES + val startAngle = if (forward) FORWARD_START else BACK_START + val sweepAngle = if (forward) SWEEP else -SWEEP + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE } + return createBitmap(size.toInt(), size.toInt()).also { bitmap -> + val canvas = Canvas(bitmap) + paint.style = Paint.Style.STROKE + paint.strokeWidth = stroke + paint.strokeCap = Paint.Cap.ROUND + canvas.drawArc(RectF(inset, inset, size - inset, size - inset), startAngle, sweepAngle, false, paint) + + paint.style = Paint.Style.FILL + canvas.drawPath(arrowHead(size, stroke, inset, startAngle + sweepAngle, forward), paint) + + paint.typeface = + GoogleSansTypefaces.create( + context = context, + weight = NUMBER_WEIGHT, + roundness = GoogleSansTypefaces.cachedRoundness(context), + ) + paint.textAlign = Paint.Align.CENTER + paint.textSize = size * if (seconds >= THREE_DIGITS) NUMBER_SIZE_LONG else NUMBER_SIZE + val metrics = paint.fontMetrics + canvas.drawText(seconds.toString(), size / 2f, size / 2f - (metrics.ascent + metrics.descent) / 2f, paint) + } + } + + private fun arrowHead(size: Float, stroke: Float, inset: Float, endAngle: Float, forward: Boolean): Path { + val endRadians = Math.toRadians(endAngle.toDouble()) + val radius = (size - inset * 2f) / 2f + val center = size / 2f + val endX = center + radius * cos(endRadians).toFloat() + val endY = center + radius * sin(endRadians).toFloat() + val direction = if (forward) 1f else -1f + val tangentX = -sin(endRadians).toFloat() * direction + val tangentY = cos(endRadians).toFloat() * direction + val head = stroke * HEAD_STROKES + val tipX = endX + tangentX * head * HEAD_TIP + val tipY = endY + tangentY * head * HEAD_TIP + val baseX = endX - tangentX * head * HEAD_BASE + val baseY = endY - tangentY * head * HEAD_BASE + // Perpendicular to the tangent. + val sideX = -tangentY * head * HEAD_TIP + val sideY = tangentX * head * HEAD_TIP + return Path().apply { + moveTo(tipX, tipY) + lineTo(baseX + sideX, baseY + sideY) + lineTo(baseX - sideX, baseY - sideY) + close() + } + } + + private const val STROKE = 0.085f + private const val INSET_STROKES = 1.35f + private const val FORWARD_START = -55f + private const val BACK_START = 235f + private const val SWEEP = 290f + private const val HEAD_STROKES = 2.25f + private const val HEAD_TIP = 0.55f + private const val HEAD_BASE = 0.45f + private const val NUMBER_WEIGHT = 700 + private const val NUMBER_SIZE = 0.3f + private const val NUMBER_SIZE_LONG = 0.24f + private const val THREE_DIGITS = 100 +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt new file mode 100644 index 000000000..f33133e7c --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetShapes.kt @@ -0,0 +1,207 @@ +package cx.aswin.boxlore.feature.widgets + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.Shader +import androidx.core.graphics.createBitmap +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.sin + +/** Outlines the widgets cut artwork and placeholders into. */ +internal enum class WidgetArtShape { + CIRCLE, + COOKIE, + ARCH, + FLOWER, + ROUNDED, +} + +/** + * Shape outlines and the bitmaps cut from them. Masks are white, so the launcher tints them with a + * [WidgetChrome] colour; artwork keeps its own colours. + */ +internal object WidgetShapes { + /** Tile shapes for the Your Shows grid when the listener chose mixed shapes, in reading order. */ + private val MIXED = + listOf( + WidgetArtShape.COOKIE, WidgetArtShape.CIRCLE, WidgetArtShape.ARCH, WidgetArtShape.FLOWER, + WidgetArtShape.CIRCLE, WidgetArtShape.FLOWER, WidgetArtShape.COOKIE, WidgetArtShape.ARCH, + WidgetArtShape.FLOWER, WidgetArtShape.COOKIE, WidgetArtShape.ARCH, WidgetArtShape.CIRCLE, + ) + + fun mixedShapeAt(position: Int): WidgetArtShape = MIXED[position.mod(MIXED.size)] + + /** The outline of [shape] filling a [size] × [size] square. */ + fun path(shape: WidgetArtShape, size: Float): Path = when (shape) { + WidgetArtShape.CIRCLE -> Path().apply { addOval(RectF(0f, 0f, size, size), Path.Direction.CW) } + WidgetArtShape.COOKIE -> scalloped(size, lobes = COOKIE_LOBES, depth = COOKIE_DEPTH) + WidgetArtShape.FLOWER -> scalloped(size, lobes = FLOWER_LOBES, depth = FLOWER_DEPTH) + WidgetArtShape.ARCH -> { + val foot = size * ARCH_FOOT_RADIUS + val crown = size / 2f + Path().apply { + addRoundRect( + RectF(0f, 0f, size, size), + floatArrayOf(crown, crown, crown, crown, foot, foot, foot, foot), + Path.Direction.CW, + ) + } + } + WidgetArtShape.ROUNDED -> { + val radius = size * ROUNDED_RADIUS + Path().apply { addRoundRect(RectF(0f, 0f, size, size), radius, radius, Path.Direction.CW) } + } + } + + /** A soft-scalloped circle: the radius swells and dips [lobes] times around the edge. */ + private fun scalloped(size: Float, lobes: Int, depth: Float): Path { + val center = size / 2f + val steps = lobes * STEPS_PER_LOBE + return Path().apply { + for (step in 0 until steps) { + val angle = 2.0 * PI * step / steps + val radius = center * ((1f - depth) + depth * cos(lobes * angle).toFloat()) + val x = center + radius * sin(angle).toFloat() + val y = center - radius * cos(angle).toFloat() + if (step == 0) moveTo(x, y) else lineTo(x, y) + } + close() + } + } + + /** + * A white [shape] on a clear ground, for placeholders the launcher tints. [insetFraction] matches + * [shapeArtwork], so a placeholder sits exactly where the artwork would. + */ + fun mask(shape: WidgetArtShape, sizePx: Int, insetFraction: Float = 0f): Bitmap { + val size = sizePx.coerceAtLeast(1) + val inset = size * insetFraction.coerceIn(0f, MAX_INSET) + return createBitmap(size, size).also { bitmap -> + Canvas(bitmap).apply { + translate(inset, inset) + drawPath(path(shape, (size - inset * 2f).coerceAtLeast(1f)), fillPaint()) + } + } + } + + /** + * [source] centre-cropped into [shape]. [insetFraction] leaves a clear band around the artwork so + * a progress ring of the same size can sit outside it. + */ + fun shapeArtwork(source: Bitmap, sizePx: Int, shape: WidgetArtShape, insetFraction: Float = 0f): Bitmap { + val size = sizePx.coerceAtLeast(1) + val inset = size * insetFraction.coerceIn(0f, MAX_INSET) + val inner = (size - inset * 2f).coerceAtLeast(1f) + val crop = minOf(source.width, source.height).toFloat() + val matrix = + Matrix().apply { + postTranslate(-(source.width - crop) / 2f, -(source.height - crop) / 2f) + postScale(inner / crop, inner / crop) + } + val paint = + Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { setLocalMatrix(matrix) } + } + return createBitmap(size, size).also { bitmap -> + Canvas(bitmap).apply { + translate(inset, inset) + drawPath(path(shape, inner), paint) + } + } + } + + /** + * The two arcs of a progress ring as white masks: what has played, then a gap, then what is left. + * Either is null when it has nothing to draw. + */ + fun progressRing(sizePx: Int, fraction: Float, strokeFraction: Float): ProgressRing { + val size = sizePx.coerceAtLeast(1) + val stroke = max(size * strokeFraction, 1f) + val radius = (size - stroke) / 2f + val arcs = WidgetRingArcs.of(fraction, gapDegrees = Math.toDegrees((stroke * GAP_STROKES / radius).toDouble()).toFloat()) + val bounds = RectF(stroke / 2f, stroke / 2f, size - stroke / 2f, size - stroke / 2f) + val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = stroke + strokeCap = Paint.Cap.ROUND + } + + fun draw(arc: WidgetRingArcs.Arc?): Bitmap? = arc?.let { + createBitmap(size, size).also { bitmap -> + Canvas(bitmap).drawArc(bounds, RING_START_DEGREES + it.startDegrees, it.sweepDegrees, false, paint) + } + } + return ProgressRing(played = draw(arcs.played), remaining = draw(arcs.remaining)) + } + + class ProgressRing(val played: Bitmap?, val remaining: Bitmap?) + + /** Decodes [path] downsampled to roughly [targetPx] on its short side, or null when unreadable. */ + fun decodeSampled(path: String, targetPx: Int): Bitmap? { + val target = targetPx.coerceAtLeast(1) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + var sampleSize = 1 + while (bounds.outWidth / (sampleSize * 2) >= target && bounds.outHeight / (sampleSize * 2) >= target) { + sampleSize *= 2 + } + return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sampleSize }) + } + + private fun fillPaint(): Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.FILL + } + + private const val COOKIE_LOBES = 12 + private const val COOKIE_DEPTH = 0.07f + private const val FLOWER_LOBES = 8 + private const val FLOWER_DEPTH = 0.1f + private const val STEPS_PER_LOBE = 12 + private const val ARCH_FOOT_RADIUS = 0.22f + private const val ROUNDED_RADIUS = 0.24f + private const val MAX_INSET = 0.4f + private const val GAP_STROKES = 1.6f + private const val RING_START_DEGREES = -90f +} + +/** + * Where a progress ring's arcs start and how far they sweep, in degrees clockwise from the top. + * Pure geometry so it can be tested without a canvas. + */ +internal data class WidgetRingArcs(val played: Arc?, val remaining: Arc?) { + data class Arc(val startDegrees: Float, val sweepDegrees: Float) + + companion object { + private const val FULL = 360f + + /** A sliver of progress still shows as a visible dot. */ + private const val MIN_PLAYED_SWEEP = 2f + + fun of(fraction: Float, gapDegrees: Float): WidgetRingArcs { + val clamped = if (fraction.isNaN()) 0f else fraction.coerceIn(0f, 1f) + val gap = gapDegrees.coerceIn(0f, FULL / 4f) + if (clamped <= 0f) return WidgetRingArcs(played = null, remaining = Arc(0f, FULL)) + val playedSweep = (FULL * clamped).coerceAtLeast(MIN_PLAYED_SWEEP) + val remainingSweep = FULL - playedSweep - gap * 2f + // Too little left for a separate arc: the played arc closes the ring. + if (remainingSweep <= gap) return WidgetRingArcs(played = Arc(0f, FULL), remaining = null) + return WidgetRingArcs( + played = Arc(0f, playedSweep), + remaining = Arc(playedSweep + gap, remainingSweep), + ) + } + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt deleted file mode 100644 index f575a6b7d..000000000 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRenderer.kt +++ /dev/null @@ -1,109 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.text.Layout -import android.text.StaticLayout -import android.text.TextPaint -import android.text.TextUtils -import androidx.annotation.ColorInt -import androidx.annotation.ColorRes -import androidx.core.content.ContextCompat -import cx.aswin.boxlore.core.designsystem.theme.GoogleSansTypefaces - -/** - * Rasterizes widget labels with the exact Google Sans Flex ROND selected in Appearance. - * - * Launcher hosts are inconsistent about applying variable-font settings from RemoteViews. - * A bitmap makes the typeface, line count, and clipping deterministic across launchers. - */ -object WidgetTextBitmapRenderer { - data class Spec( - val text: String, - val widthDp: Int, - val heightDp: Int, - val preferredSizeSp: Float, - val minSizeSp: Float, - val weight: Int, - val maxLines: Int, - val alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL, - ) - - fun render( - context: Context, - spec: Spec, - @ColorRes colorRes: Int, - ): Bitmap = renderColor( - context = context, - spec = spec, - color = ContextCompat.getColor(context, colorRes), - ) - - fun renderColor( - context: Context, - spec: Spec, - @ColorInt color: Int, - ): Bitmap { - val density = context.resources.displayMetrics.density - val scaledDensity = density * context.resources.configuration.fontScale - val widthPx = (spec.widthDp * density).toInt().coerceAtLeast(1) - val heightPx = (spec.heightDp * density).toInt().coerceAtLeast(1) - val paint = - TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply { - this.color = color - typeface = - GoogleSansTypefaces.create( - context = context, - weight = spec.weight, - roundness = GoogleSansTypefaces.cachedRoundness(context), - ) - } - - var low = spec.minSizeSp - var high = spec.preferredSizeSp.coerceAtLeast(spec.minSizeSp) - var best = spec.minSizeSp - repeat(FIT_ITERATIONS) { - val candidate = (low + high) / 2f - paint.textSize = candidate * scaledDensity - val layout = buildLayout(spec.text, paint, widthPx, spec.maxLines, spec.alignment) - if (layout.height <= heightPx && !isEllipsized(layout)) { - best = candidate - low = candidate - } else { - high = candidate - } - } - - paint.textSize = best * scaledDensity - val layout = buildLayout(spec.text, paint, widthPx, spec.maxLines, spec.alignment) - return Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also { bitmap -> - val canvas = Canvas(bitmap) - canvas.translate(0f, ((heightPx - layout.height) / 2f).coerceAtLeast(0f)) - layout.draw(canvas) - } - } - - private fun buildLayout( - text: String, - paint: TextPaint, - widthPx: Int, - maxLines: Int, - alignment: Layout.Alignment, - ): StaticLayout = StaticLayout - .Builder - .obtain(text, 0, text.length, paint, widthPx) - .setAlignment(alignment) - .setIncludePad(false) - .setLineSpacing(0f, 1f) - .setMaxLines(maxLines) - .setEllipsize(TextUtils.TruncateAt.END) - .build() - - private fun isEllipsized(layout: StaticLayout): Boolean = (0 until layout.lineCount).any { line -> - layout.getEllipsisCount(line) > 0 - } - - private const val FIT_ITERATIONS = 8 -} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt index bc83a1ced..2218d8a10 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/actions/WidgetActionHandler.kt @@ -4,8 +4,10 @@ import android.content.Context import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetCoordinator import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependencies import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetDependenciesHolder +import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetSnapshot import cx.aswin.boxlore.feature.widgets.NowPlayingWidgetSnapshotStore import cx.aswin.boxlore.feature.widgets.WidgetControl +import cx.aswin.boxlore.feature.widgets.WidgetPlaybackSource import cx.aswin.boxlore.feature.widgets.logic.NowPlayingWidgetMapper import cx.aswin.boxlore.feature.widgets.logic.WidgetOptimisticAction @@ -20,7 +22,8 @@ object WidgetActionHandler { val current = store.read() var wroteOptimistic = false if (current != null && control != WidgetControl.OPEN_APP) { - val optimistic = WidgetOptimisticAction.apply(current, control) + val optimistic = WidgetOptimisticAction.apply(withLivePosition(current, deps), control) + // Redrawn straight away from the expected result, so the widget never waits for the player. store.write(optimistic) NowPlayingWidgetCoordinator.requestRefresh(deps.context) wroteOptimistic = true @@ -30,14 +33,7 @@ object WidgetActionHandler { if (control != WidgetControl.OPEN_APP) { deps.playback.restoreBeforeAction() } - when (control) { - WidgetControl.TOGGLE -> deps.playback.togglePlayPause() - WidgetControl.PREVIOUS -> deps.playback.previous() - WidgetControl.NEXT -> deps.playback.next() - WidgetControl.SKIP_BACK -> deps.playback.skipBackward() - WidgetControl.SKIP_FORWARD -> deps.playback.skipForward() - WidgetControl.OPEN_APP -> Unit - } + perform(control, deps.playback) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e if (wroteOptimistic) { @@ -46,6 +42,32 @@ object WidgetActionHandler { } } + private suspend fun perform(control: WidgetControl, playback: WidgetPlaybackSource) { + when (control) { + WidgetControl.TOGGLE -> playback.togglePlayPause() + WidgetControl.PREVIOUS -> playback.previous() + WidgetControl.NEXT -> playback.next() + WidgetControl.SKIP_BACK -> playback.skipBackward() + WidgetControl.SKIP_FORWARD -> playback.skipForward() + WidgetControl.OPEN_APP -> return + } + // Replace the optimistic position with the player's real one, also while the app is in the background. + playback.refreshPosition() + } + + /** + * The saved snapshot only moves when the widget repaints, so a seek is previewed from the position + * the player last published instead. + */ + private fun withLivePosition( + snapshot: NowPlayingWidgetSnapshot, + deps: NowPlayingWidgetDependencies, + ): NowPlayingWidgetSnapshot { + val live = deps.playback.state.value + if (live.episodeId != snapshot.episodeId) return snapshot + return snapshot.copy(positionMs = live.positionMs, durationMs = live.durationMs) + } + private fun refreshFromAuthoritativePlayback( context: Context, store: NowPlayingWidgetSnapshotStore, diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt new file mode 100644 index 000000000..c489431f6 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldown.kt @@ -0,0 +1,37 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import java.util.concurrent.ConcurrentHashMap + +/** + * Remembers cover requests that ran out of time while a list row was waiting for them, so the rows + * after it, and the same row on the next redraw, are not each kept waiting again. A request that + * fails outright is the loader's to remember; this only covers the ones cut off before they finished. + */ +internal class ArtworkTimeoutCooldown( + private val cooldownMs: Long = COOLDOWN_MS, + private val nowMs: () -> Long, +) { + private val timedOutAt = ConcurrentHashMap() + + /** Whether this cover, with these fallbacks, timed out recently and should not be waited for now. */ + fun isCoolingDown(url: String?, fallbackUrls: List): Boolean { + val key = key(url, fallbackUrls) + val at = timedOutAt[key] ?: return false + if (nowMs() - at < cooldownMs) return true + timedOutAt.remove(key, at) + return false + } + + fun recordTimeout(url: String?, fallbackUrls: List) { + if (timedOutAt.size >= MAX_ENTRIES) timedOutAt.clear() + timedOutAt[key(url, fallbackUrls)] = nowMs() + } + + // The fallbacks are part of the request: the same cover with another fallback is another attempt. + private fun key(url: String?, fallbackUrls: List): String = (listOf(url.orEmpty()) + fallbackUrls).joinToString("\n") + + internal companion object { + const val COOLDOWN_MS = 5 * 60_000L + private const val MAX_ENTRIES = 256 + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt new file mode 100644 index 000000000..585042351 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatches.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import cx.aswin.boxlore.core.model.artworkUrls +import cx.aswin.boxlore.feature.widgets.LibraryWidgetSnapshot +import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow +import cx.aswin.boxlore.feature.widgets.WidgetShowRow + +/** + * Fetches the covers a library snapshot is missing and hands back the snapshot as they arrive, a few + * at a time. Waiting for the whole library first left a widget without covers whenever the app was + * closed before the last one came in. + */ +internal object LibraryArtworkBatches { + const val PUBLISH_EVERY = 4 + + /** + * Loads every missing cover in [snapshot] through [load], shows first, then episodes. [publish] + * gets the snapshot with the covers so far after every [publishEvery] loads that found one, and + * once more for the rest. A cover that cannot be loaded is skipped. Cancelling the caller stops + * the loading, and nothing is published after that. + */ + suspend fun load( + snapshot: LibraryWidgetSnapshot, + publishEvery: Int = PUBLISH_EVERY, + load: suspend (url: String?, fallbackUrls: List) -> String?, + publish: (LibraryWidgetSnapshot) -> Unit, + ) { + var current = snapshot + var unpublished = 0 + + fun loaded(next: LibraryWidgetSnapshot) { + current = next + unpublished++ + if (unpublished >= publishEvery) { + publish(current) + unpublished = 0 + } + } + + snapshot.subscriptions.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + loaded(current.copy(subscriptions = current.subscriptions.toMutableList().also { it[index] = row.copy(artworkCachePath = path) })) + } + snapshot.newEpisodes.forEachIndexed { index, row -> + if (!row.needsArtwork()) return@forEachIndexed + val path = load(row.artworkUrl, row.fallbackArtworkUrls) ?: return@forEachIndexed + loaded(current.copy(newEpisodes = current.newEpisodes.toMutableList().also { it[index] = row.copy(artworkCachePath = path) })) + } + if (unpublished > 0) publish(current) + } + + private fun WidgetShowRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() + + private fun WidgetEpisodeRow.needsArtwork(): Boolean = artworkCachePath.isNullOrBlank() && artworkUrls(artworkUrl, *fallbackArtworkUrls.toTypedArray()).isNotEmpty() +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt index 77ed738e3..e2cfe372e 100644 --- a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogic.kt @@ -40,7 +40,22 @@ object WidgetUpdatePolicy { previous.isLoading != next.isLoading || previous.artworkCachePath != next.artworkCachePath || previous.episodeTitle != next.episodeTitle || - previous.podcastTitle != next.podcastTitle + previous.podcastTitle != next.podcastTitle || + hasProgressChange(previous, next) || + previous.seekBackwardMs != next.seekBackwardMs || + previous.seekForwardMs != next.seekForwardMs + + /** + * The position moves many times a second, but the ring and the time-left label only change now and + * then. Repainting on those steps keeps a playing widget to about one update a minute. + */ + private fun hasProgressChange( + previous: NowPlayingWidgetSnapshot, + next: NowPlayingWidgetSnapshot, + ): Boolean = WidgetProgress.ringStep(previous.positionMs, previous.durationMs) != + WidgetProgress.ringStep(next.positionMs, next.durationMs) || + WidgetProgress.minutesLeft(previous.positionMs, previous.durationMs) != + WidgetProgress.minutesLeft(next.positionMs, next.durationMs) } object WidgetOptimisticAction { @@ -85,6 +100,9 @@ object WidgetSemantics { append(snapshot.podcastTitle) } append(if (snapshot.isPlaying) ", playing" else ", paused") + WidgetProgress.minutesLeft(snapshot.positionMs, snapshot.durationMs) + ?.takeIf { it > 0 } + ?.let { minutes -> append(if (minutes == 1) ", 1 minute left" else ", $minutes minutes left") } } } } diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt new file mode 100644 index 000000000..0a9686a1a --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentation.kt @@ -0,0 +1,116 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlin.math.ceil + +/** + * What a playback widget shows at the size the launcher reports: how many title lines, whether there + * is room for the time left and the show name, and which shape of layout to use. + * + * The layouts scale themselves to the space they really get and keep their own proportions, so + * nothing here can clip a widget: a late or wrong report only makes these choices a little too + * generous or too cautious. The shape limits below repeat the ones the layouts' frames enforce. + */ +object WidgetPresentation { + /** What sits under the episode title. The show name goes first when space runs out. */ + enum class Meta { + TIME_AND_SHOW, + TIME, + NONE, + } + + /** Now Playing · Full Controls. */ + data class FullControls( + /** The taller shape: a large info block over the controls. */ + val tall: Boolean, + val titleLines: Int, + val meta: Meta, + ) + + fun fullControls(size: WidgetSize): FullControls { + val tall = size.heightDp >= FULL_TALL_FROM && size.widthDp >= FULL_TALL_MIN_WIDTH + // The frame stops growing once it is this tall for its width; the rest of the cell stays empty. + val content = minOf(size.heightDp.toFloat(), size.widthDp * if (tall) FULL_TALL_MAX_SHAPE else FULL_MAX_SHAPE) + val infoRow = if (tall) (content - ROW_GAP) * TALL_INFO_SHARE else (content - ROW_GAP) / 2f + return FullControls( + tall = tall, + titleLines = + when { + tall -> TALL_TITLE_LINES + infoRow >= TWO_LINE_ROW -> 2 + else -> 1 + }, + meta = + when { + infoRow >= SHOW_NAME_ROW -> Meta.TIME_AND_SHOW + infoRow >= TIME_ROW -> Meta.TIME + else -> Meta.NONE + }, + ) + } + + /** Now Playing · Compact. */ + data class Bar( + val titleLines: Int, + /** The artwork already names the show, so the second line is the time left, when there is room. */ + val showTimeLeft: Boolean, + ) + + fun bar(size: WidgetSize): Bar { + // The pill fills the cell until it is a quarter as tall as it is wide, then stays that slim. + val pill = minOf(size.heightDp.toFloat(), size.widthDp * BAR_MAX_SHAPE) + return Bar( + titleLines = if (pill >= BAR_TWO_LINE_PILL) 2 else 1, + showTimeLeft = pill >= BAR_TIME_PILL, + ) + } + + /** + * The sizes at which Full Controls changes what it shows, smallest first: the time left appears, + * then the show name, then a second title line, then the taller shape. Each is the smallest + * widget whose frame has room for that step, and each fits inside the next. + */ + val FULL_CONTROLS_STEPS: List = + listOf( + WidgetSize(0, 0), + frameWith(infoRow = TIME_ROW), + frameWith(infoRow = SHOW_NAME_ROW), + frameWith(infoRow = TWO_LINE_ROW), + WidgetSize(FULL_TALL_MIN_WIDTH, FULL_TALL_FROM), + ) + + /** The sizes at which Compact changes what it shows: a second title line, then the time left. */ + val BAR_STEPS: List = listOf(WidgetSize(0, 0), pillOf(BAR_TWO_LINE_PILL), pillOf(BAR_TIME_PILL)) + + /** + * The step a launcher draws for a widget really [drawn] this big: the largest one that fits, as + * `RemoteViews` chooses among sized layouts. The first step fits everything. + */ + fun stepFor(steps: List, drawn: WidgetSize): WidgetSize = + steps.lastOrNull { it.widthDp <= drawn.widthDp && it.heightDp <= drawn.heightDp } ?: steps.first() + + /** The smallest regular Full Controls widget whose info row is [infoRow] tall. */ + private fun frameWith(infoRow: Float): WidgetSize { + val frame = infoRow * 2 + ROW_GAP + return WidgetSize(ceil(frame / FULL_MAX_SHAPE).toInt(), ceil(frame).toInt()) + } + + /** The smallest Compact widget whose pill is [pill] tall. */ + private fun pillOf(pill: Float): WidgetSize = WidgetSize(ceil(pill / BAR_MAX_SHAPE).toInt(), ceil(pill).toInt()) + + private const val FULL_TALL_FROM = 236 + + // As wide as the widest regular step, so every step fits inside the next. + private const val FULL_TALL_MIN_WIDTH = 267 + private const val FULL_MAX_SHAPE = 0.6f + private const val FULL_TALL_MAX_SHAPE = 0.8f + private const val ROW_GAP = 8f + private const val TALL_INFO_SHARE = 21f / 31f + private const val TALL_TITLE_LINES = 3 + private const val TWO_LINE_ROW = 76f + private const val SHOW_NAME_ROW = 60f + private const val TIME_ROW = 46f + + private const val BAR_MAX_SHAPE = 0.25f + private const val BAR_TWO_LINE_PILL = 60f + private const val BAR_TIME_PILL = 68f +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt new file mode 100644 index 000000000..154b28594 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgress.kt @@ -0,0 +1,38 @@ +package cx.aswin.boxlore.feature.widgets.logic + +/** What the progress ring and the time-left label show, derived from a playback position. */ +object WidgetProgress { + /** How many visible steps the ring has. A position change inside one step is not worth a repaint. */ + const val RING_STEPS = 120 + + private const val MS_PER_MINUTE = 60_000L + private const val MINUTES_PER_HOUR = 60 + + /** Played share of the episode, or 0 while the length is unknown. */ + fun fraction(positionMs: Long, durationMs: Long): Float { + if (durationMs <= 0L) return 0f + return (positionMs.coerceIn(0L, durationMs).toDouble() / durationMs).toFloat() + } + + /** The ring step [positionMs] falls in, from 0 to [RING_STEPS]. */ + fun ringStep(positionMs: Long, durationMs: Long): Int = (fraction(positionMs, durationMs) * RING_STEPS).toInt() + + /** + * Whole minutes left, rounded up so the label reaches zero only when the episode ends, or null while + * the length is unknown. + */ + fun minutesLeft(positionMs: Long, durationMs: Long): Int? { + if (durationMs <= 0L) return null + val remainingMs = (durationMs - positionMs.coerceIn(0L, durationMs)) + return ((remainingMs + MS_PER_MINUTE - 1) / MS_PER_MINUTE).toInt() + } + + /** Hours and minutes of a [minutesLeft] value, for the label. */ + data class TimeLeft(val hours: Int, val minutes: Int) + + fun timeLeft(positionMs: Long, durationMs: Long): TimeLeft? { + val minutes = minutesLeft(positionMs, durationMs) ?: return null + if (minutes <= 0) return null + return TimeLeft(hours = minutes / MINUTES_PER_HOUR, minutes = minutes % MINUTES_PER_HOUR) + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt new file mode 100644 index 000000000..724bcca52 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPoller.kt @@ -0,0 +1,29 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.isActive + +/** + * Asks the player for its position at a steady pace while an episode plays, and not at all while it is + * paused or no playback widget is placed. The app only tracks the position while it is on screen, which + * is exactly when a home-screen widget is not being looked at. + */ +object WidgetProgressPoller { + suspend fun run( + playing: Flow, + intervalMs: Long, + hasWidgets: () -> Boolean, + refresh: suspend () -> Unit, + ) { + playing.distinctUntilChanged().collectLatest { isPlaying -> + while (isPlaying && currentCoroutineContext().isActive) { + delay(intervalMs) + if (hasWidgets()) refresh() + } + } + } +} diff --git a/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt new file mode 100644 index 000000000..31774c4f9 --- /dev/null +++ b/feature/widgets/src/main/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizes.kt @@ -0,0 +1,32 @@ +package cx.aswin.boxlore.feature.widgets.logic + +/** A widget's size in dp, as the launcher reports it. */ +data class WidgetSize(val widthDp: Int, val heightDp: Int) { + val shortSide: Int get() = minOf(widthDp, heightDp) +} + +/** + * The two sizes a launcher reports for a widget: its narrowest width with its tallest height (portrait) + * and its widest width with its shortest height (landscape). Launchers report these late, loosely or + * not at all, so they only guide what a widget shows; the layouts scale themselves to the real space. + */ +data class WidgetSizes(val portrait: WidgetSize, val landscape: WidgetSize) { + /** The most space the widget may have: pictures drawn for this stay sharp either way round. */ + val largest: WidgetSize get() = WidgetSize(maxOf(portrait.widthDp, landscape.widthDp), maxOf(portrait.heightDp, landscape.heightDp)) + + companion object { + /** Values at or below zero are missing: some launchers leave them out until the first resize. */ + fun of(minWidthDp: Int, maxWidthDp: Int, minHeightDp: Int, maxHeightDp: Int, fallback: WidgetSize): WidgetSizes { + val narrow = firstPositive(minWidthDp, maxWidthDp, fallback.widthDp) + val wide = firstPositive(maxWidthDp, minWidthDp, fallback.widthDp) + val short = firstPositive(minHeightDp, maxHeightDp, fallback.heightDp) + val tall = firstPositive(maxHeightDp, minHeightDp, fallback.heightDp) + return WidgetSizes( + portrait = WidgetSize(minOf(narrow, wide), maxOf(short, tall)), + landscape = WidgetSize(maxOf(narrow, wide), minOf(short, tall)), + ) + } + + private fun firstPositive(vararg values: Int): Int = values.firstOrNull { it > 0 } ?: 1 + } +} diff --git a/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml b/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml new file mode 100644 index 000000000..37b2ac385 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/ic_widget_arrow_forward.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml b/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml new file mode 100644 index 000000000..58a292aa0 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/ic_widget_caught_up.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml b/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml deleted file mode 100644 index 667891a34..000000000 --- a/feature/widgets/src/main/res/drawable/widget_art_placeholder.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_button_secondary.xml b/feature/widgets/src/main/res/drawable/widget_block.xml similarity index 82% rename from feature/widgets/src/main/res/drawable/widget_button_secondary.xml rename to feature/widgets/src/main/res/drawable/widget_block.xml index f9b58d51d..8cc8d5081 100644 --- a/feature/widgets/src/main/res/drawable/widget_button_secondary.xml +++ b/feature/widgets/src/main/res/drawable/widget_block.xml @@ -1,6 +1,6 @@ - + diff --git a/feature/widgets/src/main/res/drawable/widget_button_pause.xml b/feature/widgets/src/main/res/drawable/widget_button_pause.xml deleted file mode 100644 index f9b58d51d..000000000 --- a/feature/widgets/src/main/res/drawable/widget_button_pause.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_button_play.xml b/feature/widgets/src/main/res/drawable/widget_button_play.xml deleted file mode 100644 index 59941f517..000000000 --- a/feature/widgets/src/main/res/drawable/widget_button_play.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_circle.xml b/feature/widgets/src/main/res/drawable/widget_circle.xml new file mode 100644 index 000000000..fd235a65e --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_circle.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_grid_surface.xml b/feature/widgets/src/main/res/drawable/widget_container.xml similarity index 82% rename from feature/widgets/src/main/res/drawable/widget_grid_surface.xml rename to feature/widgets/src/main/res/drawable/widget_container.xml index 46bf5ad72..a982b8306 100644 --- a/feature/widgets/src/main/res/drawable/widget_grid_surface.xml +++ b/feature/widgets/src/main/res/drawable/widget_container.xml @@ -1,6 +1,6 @@ - + diff --git a/feature/widgets/src/main/res/drawable/widget_cookie.xml b/feature/widgets/src/main/res/drawable/widget_cookie.xml new file mode 100644 index 000000000..2e68c3eb5 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_cookie.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml b/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml deleted file mode 100644 index 7e1621e83..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_footer_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml b/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml deleted file mode 100644 index 3ab1628d6..000000000 --- a/feature/widgets/src/main/res/drawable/widget_list_row_chip.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_new_dot.xml b/feature/widgets/src/main/res/drawable/widget_new_dot.xml deleted file mode 100644 index 4d6a435b1..000000000 --- a/feature/widgets/src/main/res/drawable/widget_new_dot.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/feature/widgets/src/main/res/drawable/widget_list_count_chip.xml b/feature/widgets/src/main/res/drawable/widget_pill.xml similarity index 72% rename from feature/widgets/src/main/res/drawable/widget_list_count_chip.xml rename to feature/widgets/src/main/res/drawable/widget_pill.xml index 7e1621e83..2349793b3 100644 --- a/feature/widgets/src/main/res/drawable/widget_list_count_chip.xml +++ b/feature/widgets/src/main/res/drawable/widget_pill.xml @@ -1,4 +1,5 @@ + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml b/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml new file mode 100644 index 000000000..11f6a50a7 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_bar_play.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml b/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml new file mode 100644 index 000000000..03cb7adcf --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_fill.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml new file mode 100644 index 000000000..9647f60cb --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml new file mode 100644 index 000000000..97487ea97 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame_bar.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml b/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml new file mode 100644 index 000000000..8ff5cd298 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_frame_tall.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml b/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml new file mode 100644 index 000000000..ec1fc90a8 --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_mini.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_sizer_square.xml b/feature/widgets/src/main/res/drawable/widget_sizer_square.xml new file mode 100644 index 000000000..765ee4cca --- /dev/null +++ b/feature/widgets/src/main/res/drawable/widget_sizer_square.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/feature/widgets/src/main/res/drawable/widget_surface.xml b/feature/widgets/src/main/res/drawable/widget_surface.xml deleted file mode 100644 index 23f7ac5f4..000000000 --- a/feature/widgets/src/main/res/drawable/widget_surface.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml b/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml index f6fed60fe..215f6cf97 100644 --- a/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml +++ b/feature/widgets/src/main/res/layout/library_widget_episode_list_item.xml @@ -1,4 +1,5 @@ + + android:src="@drawable/widget_pill" /> + android:paddingStart="6dp" + android:paddingEnd="22dp"> + android:layout_width="46dp" + android:layout_height="46dp"> + + + + + android:scaleType="fitXY" /> + android:orientation="vertical" + android:paddingVertical="7dp"> + android:textColor="@color/widget_on_surface" /> + android:textColor="@color/widget_on_surface_variant" /> - - diff --git a/feature/widgets/src/main/res/layout/library_widget_grid.xml b/feature/widgets/src/main/res/layout/library_widget_grid.xml new file mode 100644 index 000000000..686016bc5 --- /dev/null +++ b/feature/widgets/src/main/res/layout/library_widget_grid.xml @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/library_widget_list.xml b/feature/widgets/src/main/res/layout/library_widget_list.xml index c1b1b354e..0e0c614ec 100644 --- a/feature/widgets/src/main/res/layout/library_widget_list.xml +++ b/feature/widgets/src/main/res/layout/library_widget_list.xml @@ -1,4 +1,8 @@ + + android:layout_height="match_parent"> + android:src="@drawable/widget_container" /> + android:paddingTop="14dp" + android:paddingBottom="14dp"> + android:baselineAligned="false" + android:orientation="horizontal"> + + + + + + + + android:textColor="@color/widget_on_surface" /> + android:src="@drawable/widget_circle" /> - + android:autoMirrored="true" + android:contentDescription="@string/library_widget_view_all" + android:scaleType="fitCenter" + android:src="@drawable/ic_widget_arrow_forward" /> @@ -88,10 +119,10 @@ android:id="@+id/widget_list_view" android:layout_width="match_parent" android:layout_height="match_parent" + android:layout_marginHorizontal="14dp" android:divider="@null" android:dividerHeight="0dp" - android:fadeScrollbars="true" - android:scrollbarStyle="outsideOverlay" /> + android:scrollbars="none" /> + + + + + + + + android:textSize="17sp" /> @@ -126,7 +180,7 @@ android:layout_height="match_parent" android:contentDescription="@null" android:scaleType="fitXY" - android:src="@drawable/widget_list_footer_chip" /> + android:src="@drawable/widget_pill" /> + android:paddingHorizontal="22dp" + android:textColor="@color/widget_on_surface" + android:textSize="14sp" /> - - - - - - - diff --git a/feature/widgets/src/main/res/layout/library_widget_list_item.xml b/feature/widgets/src/main/res/layout/library_widget_list_item.xml index 75e7ca386..9a3a90d2d 100644 --- a/feature/widgets/src/main/res/layout/library_widget_list_item.xml +++ b/feature/widgets/src/main/res/layout/library_widget_list_item.xml @@ -1,78 +1,63 @@ + + android:layout_height="wrap_content" + android:padding="4dp"> - + android:layout_height="wrap_content"> - - - + - - + - - - + android:adjustViewBounds="true" + android:contentDescription="@null" + android:scaleType="fitXY" /> - - + - + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml b/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml index 0d9268aa7..ca291fe98 100644 --- a/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml +++ b/feature/widgets/src/main/res/layout/now_playing_widget_bar.xml @@ -1,221 +1,423 @@ + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="RtlHardcoded,NestedWeights,UselessLeaf"> - + android:layout_height="match_parent"> + android:src="@drawable/widget_sizer_frame_bar" /> - + + android:id="@+id/widget_playing_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="gone"> - + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - - - - - - - - + android:baselineAligned="false" + android:layoutDirection="ltr" + android:orientation="horizontal"> - + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:id="@+id/widget_empty_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:visibility="visible"> + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml b/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml index c41768927..314587e91 100644 --- a/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml +++ b/feature/widgets/src/main/res/layout/now_playing_widget_standard.xml @@ -1,286 +1,482 @@ + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="RtlHardcoded,NestedWeights,UselessLeaf"> - + android:layout_height="match_parent"> - - - - - - - - - - - + android:layout_centerVertical="true" + android:adjustViewBounds="true" + android:contentDescription="@null" + android:src="@drawable/widget_sizer_frame" /> - - - - - + - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:visibility="gone"> - - - + android:src="@drawable/widget_pill" /> - - - - - + android:baselineAligned="false" + android:orientation="horizontal" + android:paddingStart="4dp" + android:paddingTop="4dp" + android:paddingEnd="22dp" + android:paddingBottom="4dp"> + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + android:layout_marginLeft="8dp" + android:layout_weight="1"> + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + android:visibility="visible"> - + android:contentDescription="@null" + android:scaleType="fitXY" + android:src="@drawable/widget_pill" /> - - - + + + + + + + + + + + + + + + + + + + - - + + diff --git a/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml b/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml new file mode 100644 index 000000000..3526b66c4 --- /dev/null +++ b/feature/widgets/src/main/res/layout/now_playing_widget_standard_tall.xml @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/widgets/src/main/res/layout/playback_controls_widget.xml b/feature/widgets/src/main/res/layout/playback_controls_widget.xml index c78360991..b54e86585 100644 --- a/feature/widgets/src/main/res/layout/playback_controls_widget.xml +++ b/feature/widgets/src/main/res/layout/playback_controls_widget.xml @@ -1,174 +1,308 @@ + + android:theme="@android:style/Theme.DeviceDefault.DayNight" + tools:ignore="NestedWeights,UselessLeaf"> - + + + android:src="@drawable/widget_circle" /> - + + + + + + + + + + + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="79" /> - + + - + android:layout_weight="13" /> - + + + - + + + + + - - - + android:layout_weight="42"> + + + + + + + + + + + android:layout_height="match_parent" + android:orientation="vertical"> + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="92" /> + + - - - - + android:layout_weight="53" /> - + + + - + + + - - - + android:layout_weight="53" /> + + + - + - - + android:layout_height="0dp" + android:layout_weight="17" /> + + + + + - - - - - - - + + + + + diff --git a/feature/widgets/src/main/res/values-night-v31/colors.xml b/feature/widgets/src/main/res/values-night-v31/colors.xml index a1fb8d9cb..7980a987e 100644 --- a/feature/widgets/src/main/res/values-night-v31/colors.xml +++ b/feature/widgets/src/main/res/values-night-v31/colors.xml @@ -10,4 +10,8 @@ @android:color/system_accent1_100 @android:color/system_neutral2_700 @android:color/system_neutral2_100 + @android:color/system_accent3_200 + @android:color/system_accent3_800 + @android:color/system_accent3_700 + @android:color/system_accent3_100 diff --git a/feature/widgets/src/main/res/values-v31/colors.xml b/feature/widgets/src/main/res/values-v31/colors.xml index ecdc91dd5..a5e562ba7 100644 --- a/feature/widgets/src/main/res/values-v31/colors.xml +++ b/feature/widgets/src/main/res/values-v31/colors.xml @@ -10,4 +10,8 @@ @android:color/system_accent1_900 @android:color/system_neutral2_100 @android:color/system_neutral2_900 + @android:color/system_accent3_600 + @android:color/system_accent3_0 + @android:color/system_accent3_100 + @android:color/system_accent3_900 diff --git a/feature/widgets/src/main/res/values/colors.xml b/feature/widgets/src/main/res/values/colors.xml index f6035c5ee..a04af2dad 100644 --- a/feature/widgets/src/main/res/values/colors.xml +++ b/feature/widgets/src/main/res/values/colors.xml @@ -9,4 +9,8 @@ #FFEADDFF #FF4A4458 #FFE8DEF8 + #FFEFB8C8 + #FF492532 + #FF633B48 + #FFFFD8E4 diff --git a/feature/widgets/src/main/res/values/strings.xml b/feature/widgets/src/main/res/values/strings.xml index 85f208f42..ca067b4fc 100644 --- a/feature/widgets/src/main/res/values/strings.xml +++ b/feature/widgets/src/main/res/values/strings.xml @@ -1,21 +1,29 @@ + Now Playing · Full Controls Now Playing · Compact - Playback Controls · Seek - Playback Controls · Next - A slim now-playing bar with quick seek controls - Artwork with play and seek controls - Artwork with play, next episode, and seek-forward controls + Now Playing · Round + Now Playing · Round with Next + + The full player: artwork, time left and all five controls + A slim one-row player with back, play and forward + A round player with back, play and forward + A round player with forward, play and next episode Ready when you are Tap to open boxlore - Ready when you are Previous episode Next episode Seek backward Seek forward Play Pause + + %1$dm left + %1$dh left + %1$dh %2$dm left + + %1$s · %2$s Why curiosity compounds The Daily Lore Field Notes @@ -27,13 +35,13 @@ How maps shape memory Soft Power 12 - Your Shows - Your subscribed shows + Library · Your Shows + Your subscribed shows as a grid of artwork Subscriptions No subscriptions yet Open boxlore - New Episodes - Latest episodes from your shows + Library · New Episodes + The latest episodes from your shows New episodes No new episodes Open subscriptions diff --git a/feature/widgets/src/main/res/xml/now_playing_widget_info.xml b/feature/widgets/src/main/res/xml/now_playing_widget_info.xml index 9733beab0..dc2743526 100644 --- a/feature/widgets/src/main/res/xml/now_playing_widget_info.xml +++ b/feature/widgets/src/main/res/xml/now_playing_widget_info.xml @@ -1,6 +1,6 @@ (R.id.widget_count_chip_text).text.toString()) + assertEquals(View.VISIBLE, root.findViewById(R.id.widget_open_list).visibility) + } + + @Test + fun restingLibraryWidgetHidesTheBadgeAndArrow() { + val root = LibraryWidgetRenderer.build(context, 6, LibraryWidgetSnapshot(), LibraryWidgetKind.NEW_EPISODES).apply(context, FrameLayout(context)) + assertEquals(View.GONE, root.findViewById(R.id.widget_count_chip).visibility) + assertEquals(View.GONE, root.findViewById(R.id.widget_open_list).visibility) + assertEquals(View.VISIBLE, root.findViewById(R.id.widget_empty_container).visibility) + assertEquals( + context.getString(R.string.new_episodes_widget_empty_title), + root.findViewById(R.id.widget_empty_title).text.toString(), + ) + } + + @Test + fun headerBadgeAndArrowStayInsideANarrowWidget() { + val snapshot = + LibraryWidgetSnapshot( + subscriptions = listOf(WidgetShowRow(podcastId = "p1", title = "Show", deepLinkUri = "boxlore://podcast/p1")), + counts = WidgetLibraryCounts(subscriptions = 128), + ) + val root = LibraryWidgetRenderer.build(context, 7, snapshot, LibraryWidgetKind.SUBSCRIPTIONS).apply(context, FrameLayout(context)) + val density = context.resources.displayMetrics.density + root.measure( + View.MeasureSpec.makeMeasureSpec((200 * density).toInt(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec((180 * density).toInt(), View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, root.measuredWidth, root.measuredHeight) + + val header = root.findViewById(R.id.widget_list_header) + val arrow = root.findViewById(R.id.widget_open_list) + val row = root.findViewById(R.id.widget_header_row) + // The title shrinks to the room it has rather than pushing the arrow out or being cut off. + assertTrue("header keeps some width", header.width > 0) + assertTrue("arrow stays inside the header row", arrow.right <= row.width) + assertTrue("title is not ellipsized", header.layout.getEllipsisCount(0) == 0) + } + @Test fun populatedListResizeFloorLeavesRoomForACompleteRow() { val views = @@ -155,7 +207,7 @@ class LibraryWidgetRemoteViewsTest { } @Test - fun newEpisodeRowsAllowTwoTitleLinesWithoutChangingSubscriptionRows() { + fun newEpisodeRowsAllowTwoTitleLinesAndShowTilesNameTheirShow() { val store = LibraryWidgetSnapshotStore(context) store.write( LibraryWidgetSnapshot( @@ -211,7 +263,11 @@ class LibraryWidgetRemoteViewsTest { val subscriptionViews = subscriptionFactory.getViewAt(0) val subscriptionRoot = subscriptionViews.apply(context, FrameLayout(context)) assertEquals(R.layout.library_widget_list_item, subscriptionViews.layoutId) - assertEquals(1, subscriptionRoot.findViewById(R.id.widget_row_title).maxLines) + // A show tile is artwork only, so its name is what a screen reader announces. + assertEquals( + "A subscription title that remains on one line", + subscriptionRoot.findViewById(R.id.widget_row_root).contentDescription, + ) episodeFactory.onDestroy() subscriptionFactory.onDestroy() diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt index 2cc4b2129..2e2ce2501 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetCoordinatorTest.kt @@ -46,6 +46,8 @@ class NowPlayingWidgetCoordinatorTest { override suspend fun skipForward() = Unit override suspend fun skipBackward() = Unit + + override suspend fun refreshPosition() = Unit } val deps = object : NowPlayingWidgetDependencies { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt index 563d9183e..d5c513445 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/NowPlayingWidgetRemoteViewsTest.kt @@ -6,14 +6,18 @@ import android.os.Bundle import android.util.SizeF import android.view.View import android.widget.FrameLayout +import android.widget.RemoteViews import android.widget.TextView import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.logic.WidgetPresentation +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode @RunWith(RobolectricTestRunner::class) @Config(sdk = [31]) @@ -23,7 +27,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun emptySnapshotUsesStandardLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 1, snapshot = NowPlayingWidgetSnapshot(), @@ -51,7 +55,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun playingSnapshotUsesStandardLayoutForDefaultOptions() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 2, snapshot = @@ -70,7 +74,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun standardLayoutCanBeInflatedByRemoteViews() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 2, snapshot = playingSnapshot(), @@ -143,28 +147,10 @@ class NowPlayingWidgetRemoteViewsTest { assertFullyInside(root, R.id.widget_empty_title) } - @Test - fun responsiveSizesUseLauncherSizeList() { - val options = - defaultOptions().apply { - putParcelableArrayList( - AppWidgetManager.OPTION_APPWIDGET_SIZES, - arrayListOf(SizeF(245f, 110f), SizeF(300f, 150f)), - ) - } - - NowPlayingWidgetRenderer.buildRemoteViews( - context = context, - appWidgetId = 2, - snapshot = playingSnapshot(), - options = options, - ) - } - @Test fun barVariantUsesDedicatedHorizontalLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 3, snapshot = playingSnapshot(), @@ -186,7 +172,7 @@ class NowPlayingWidgetRemoteViewsTest { } val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 5, snapshot = playingSnapshot(), @@ -201,7 +187,7 @@ class NowPlayingWidgetRemoteViewsTest { @Test fun controlsVariantUsesDedicatedGridLayout() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 4, snapshot = playingSnapshot(), @@ -227,9 +213,9 @@ class NowPlayingWidgetRemoteViewsTest { } @Test - fun nextControlsVariantBindsNextAndSeekForwardGrid() { + fun nextControlsVariantPutsSeekForwardLeftAndNextEpisodeRight() { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 7, snapshot = playingSnapshot(), @@ -240,11 +226,11 @@ class NowPlayingWidgetRemoteViewsTest { assertEquals(R.layout.playback_controls_widget, views.layoutId) assertEquals( - context.getString(R.string.widget_next), + context.getString(R.string.widget_skip_forward), root.findViewById(R.id.widget_skip_back_icon).contentDescription, ) assertEquals( - context.getString(R.string.widget_skip_forward), + context.getString(R.string.widget_next), root.findViewById(R.id.widget_skip_forward_icon).contentDescription, ) assertTrue( @@ -255,6 +241,186 @@ class NowPlayingWidgetRemoteViewsTest { ) } + @Test + fun metaLinePutsTheTimeLeftFirstSoALongShowNameIsWhatGetsCut() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + val both = WidgetPresentation.Meta.TIME_AND_SHOW + + assertEquals("36m left · ${snapshot.podcastTitle}", NowPlayingWidgetRenderer.metaLine(context, snapshot, both)) + assertEquals("36m left", NowPlayingWidgetRenderer.metaLine(context, snapshot, WidgetPresentation.Meta.TIME)) + assertEquals("", NowPlayingWidgetRenderer.metaLine(context, snapshot, WidgetPresentation.Meta.NONE)) + assertEquals("36m left", NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(podcastTitle = ""), both)) + // No length yet: just the show, never "0m left". + assertEquals(snapshot.podcastTitle, NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(durationMs = 0L), both)) + assertEquals("", NowPlayingWidgetRenderer.metaLine(context, snapshot.copy(durationMs = 0L), WidgetPresentation.Meta.TIME)) + } + + @Test + fun timeLeftReadsInHoursAndMinutes() { + val minute = 60_000L + val snapshot = playingSnapshot() + assertEquals("1h 12m left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 14 * minute, durationMs = 86 * minute))) + assertEquals("2h left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 0L, durationMs = 120 * minute))) + assertEquals("1m left", NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 59 * minute + 30_000L, durationMs = 60 * minute))) + assertEquals(null, NowPlayingWidgetRenderer.timeLeftLabel(context, snapshot.copy(positionMs = 60 * minute, durationMs = 60 * minute))) + } + + @Test + fun fullControlsGivesTheTitleMoreLinesAsItGrowsAndFewerAsItShrinks() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + + fun titleAt(heightDp: Int): TextView = renderAtMinimumSize(WidgetVariant.NOW_PLAYING, widthDp = 350, heightDp = heightDp, snapshot = snapshot) + .findViewById(R.id.widget_episode_title) + + val regular = renderAtMinimumSize(WidgetVariant.NOW_PLAYING, widthDp = 350, heightDp = 182, snapshot = snapshot) + assertEquals("36m left · ${snapshot.podcastTitle}", regular.findViewById(R.id.widget_podcast_title).text.toString()) + assertEquals(2, titleAt(182).maxLines) + assertEquals(3, titleAt(278).maxLines) + assertEquals(1, titleAt(100).maxLines) + assertEquals(2, titleAt(182).maxLines) + } + + @Test + fun compactPlayerKeepsTheEpisodeNameAndLeavesTheShowToTheArtwork() { + val minute = 60_000L + val snapshot = playingSnapshot().copy(positionMs = 24 * minute, durationMs = 60 * minute) + + val roomy = renderAtMinimumSize(WidgetVariant.BAR, widthDp = 350, heightDp = 86, snapshot = snapshot) + assertEquals(snapshot.episodeTitle, roomy.findViewById(R.id.widget_episode_title).text.toString()) + assertEquals("36m left", roomy.findViewById(R.id.widget_podcast_title).text.toString()) + + // A slim bar has room for the episode name only. + val slim = renderAtMinimumSize(WidgetVariant.BAR, widthDp = 350, heightDp = 52, snapshot = snapshot) + assertEquals(View.GONE, slim.findViewById(R.id.widget_podcast_title).visibility) + assertEquals(1, slim.findViewById(R.id.widget_episode_title).maxLines) + } + + @Test + fun aWidgetIsNeverClippedWhenTheLauncherReportsTheWrongSize() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines if it could") + // What the launcher said, then what the widget really got: smaller, larger, and turned on its side. + val reported = mapOf(WidgetVariant.NOW_PLAYING to (350 to 182), WidgetVariant.BAR to (350 to 86), WidgetVariant.CONTROLS to (170 to 182)) + val actual = listOf(200 to 96, 245 to 100, 300 to 60, 320 to 240, 140 to 140, 420 to 120, 120 to 260) + val playing = listOf(R.id.widget_play_pause, R.id.widget_skip_back, R.id.widget_skip_forward, R.id.widget_previous, R.id.widget_next, R.id.widget_episode_title, R.id.widget_artwork) + val resting = listOf(R.id.widget_empty_mark, R.id.widget_empty_title) + val cases = reported.flatMap { (variant, size) -> actual.flatMap { drawn -> listOf(long, NowPlayingWidgetSnapshot()).map { Triple(variant, size, drawn) to it } } } + for ((case, snapshot) in cases) { + val (variant, size, drawn) = case + val root = renderAt(variant, reportedDp = size, actualDp = drawn, snapshot = snapshot) + val where = "$variant reported ${size.first}x${size.second}, drawn ${drawn.first}x${drawn.second}" + val shown = (if (snapshot.hasEpisode) playing else resting).filter { root.findViewById(it)?.visibility == View.VISIBLE } + for (id in shown) { + val view = root.findViewById(id) + assertFullyInside(root, id, where) + assertTrue("$where: ${context.resources.getResourceEntryName(id)} has no size", view.width > 0 && view.height > 0) + } + } + } + + @Test + fun theRoundWidgetIsACircleThatFitsAnyCell() { + for ((widthDp, heightDp) in listOf(110 to 110, 170 to 182, 182 to 170, 350 to 182, 120 to 300)) { + val root = renderAtMinimumSize(WidgetVariant.CONTROLS, widthDp = widthDp, heightDp = heightDp, snapshot = playingSnapshot()) + val card = root.findViewById(R.id.widget_grid_card) + assertEquals("a circle at ${widthDp}x$heightDp", card.width, card.height) + assertEquals("as wide as the shorter side", minOf(root.width, root.height), card.width) + assertFullyInside(root, R.id.widget_grid_card) + // The play button never covers the smaller buttons beside it. + val play = root.findViewById(R.id.widget_play_pause) + val back = root.findViewById(R.id.widget_skip_back) + assertTrue("play sits below and between the side buttons", play.width > back.width && play.height > back.height) + } + } + + @Test + fun theLauncherIsGivenOneLayoutForEverySizeStepAndPicksTheSameOneWeDo() { + val pick = RemoteViews::class.java.getMethod("getRemoteViewsToApply", Context::class.java, SizeF::class.java) + val snapshot = playingSnapshot().copy(positionMs = 60_000L, durationMs = 3_600_000L) + for (variant in listOf(WidgetVariant.NOW_PLAYING, WidgetVariant.BAR)) { + // The report is deliberately wrong: what is shown must follow the space the launcher really has. + val sent = NowPlayingWidgetRenderer.buildRemoteViews(context, 4, snapshot, sizeOptions(350, 182), variant) + for (widthDp in 160..640 step 40) { + for (heightDp in 40..320 step 20) { + val launchers = (pick.invoke(sent, context, SizeF(widthDp.toFloat(), heightDp.toFloat())) as RemoteViews).apply(context, FrameLayout(context)) + val ours = + NowPlayingWidgetRenderer + .buildRemoteViewsAt(context, 4, snapshot, sizeOptions(350, 182), variant, drawn = WidgetSize(widthDp, heightDp)) + .apply(context, FrameLayout(context)) + val where = "$variant drawn ${widthDp}x$heightDp" + assertEquals(where, ours.sourceLayoutResId, launchers.sourceLayoutResId) + assertEquals(where, ours.findViewById(R.id.widget_episode_title).maxLines, launchers.findViewById(R.id.widget_episode_title).maxLines) + assertEquals(where, ours.findViewById(R.id.widget_podcast_title).text.toString(), launchers.findViewById(R.id.widget_podcast_title).text.toString()) + } + } + } + } + + @Test + fun whatAWidgetShowsFollowsTheSpaceItReallyHasNotTheReport() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines", positionMs = 60_000L, durationMs = 3_600_000L) + // Reported as a roomy 4x2, really drawn short: one title line and no show name, all of it readable. + val short = renderAt(WidgetVariant.NOW_PLAYING, reportedDp = 350 to 182, actualDp = 300 to 110, snapshot = long, pickedByLauncher = true) + assertEquals(1, short.findViewById(R.id.widget_episode_title).maxLines) + assertEquals("59m left", short.findViewById(R.id.widget_podcast_title).text.toString()) + // Reported short, really drawn roomy: the second line and the show name come back. + val roomy = renderAt(WidgetVariant.NOW_PLAYING, reportedDp = 300 to 110, actualDp = 350 to 182, snapshot = long, pickedByLauncher = true) + assertEquals(2, roomy.findViewById(R.id.widget_episode_title).maxLines) + assertEquals("59m left · Podcast", roomy.findViewById(R.id.widget_podcast_title).text.toString()) + // The round widget is one layout: it shows the same thing at every size. + assertEquals(1, NowPlayingWidgetRenderer.stepsFor(WidgetVariant.CONTROLS).size) + } + + // Real text measuring: the default test fonts do not have real line heights. + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test + fun textIsNeverCutThroughALineAtAnySizeTheLauncherCanDraw() { + val long = playingSnapshot().copy(episodeTitle = "An exceptionally long episode title that would occupy several lines if it could", positionMs = 60_000L, durationMs = 3_600_000L) + val heights = mapOf(WidgetVariant.NOW_PLAYING to (100..320 step 10), WidgetVariant.BAR to (48..200 step 8)) + val sizes = heights.flatMap { (variant, range) -> listOf(245, 300, 350, 420, 620).flatMap { widthDp -> range.map { heightDp -> Triple(variant, widthDp, heightDp) } } } + for ((variant, widthDp, heightDp) in sizes) { + val root = renderAt(variant, reportedDp = 350 to 182, actualDp = widthDp to heightDp, snapshot = long, pickedByLauncher = true) + val texts = listOf(R.id.widget_episode_title, R.id.widget_podcast_title).map { root.findViewById(it) }.filter { it.visibility == View.VISIBLE } + for (text in texts) { + val shownLines = minOf(text.layout.lineCount, text.maxLines) + val needed = text.layout.getLineBottom(shownLines - 1) + text.paddingTop + text.paddingBottom + assertTrue( + "$variant ${widthDp}x$heightDp: ${context.resources.getResourceEntryName(text.id)} needs ${needed}px for $shownLines lines, has ${text.height}px", + needed <= text.height + 1, + ) + } + } + } + + /** Builds for one size and lays out at another, the way a launcher with a stale report would. */ + private fun renderAt( + variant: WidgetVariant, + reportedDp: Pair, + actualDp: Pair, + snapshot: NowPlayingWidgetSnapshot, + pickedByLauncher: Boolean = false, + ): View { + // A launcher picks the layout for the space it has. Left to the report, this draws the wrong pick. + val drawn = if (pickedByLauncher) WidgetSize(actualDp.first, actualDp.second) else WidgetSize(reportedDp.first, reportedDp.second) + val views = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 8, snapshot, sizeOptions(reportedDp.first, reportedDp.second), variant, drawn) + return views.apply(context, FrameLayout(context)).also { root -> + val density = context.resources.displayMetrics.density + root.measure( + View.MeasureSpec.makeMeasureSpec((actualDp.first * density).toInt(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec((actualDp.second * density).toInt(), View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, root.measuredWidth, root.measuredHeight) + } + } + + private fun sizeOptions(widthDp: Int, heightDp: Int): Bundle = Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, heightDp) + } + private fun playingSnapshot() = NowPlayingWidgetSnapshot( episodeId = "ep-1", episodeTitle = "A long episode title that needs careful fitting", @@ -269,7 +435,7 @@ class NowPlayingWidgetRemoteViewsTest { snapshot: NowPlayingWidgetSnapshot, ): View { val views = - NowPlayingWidgetRenderer.buildRemoteViews( + NowPlayingWidgetRenderer.buildRemoteViewsAt( context = context, appWidgetId = 6, snapshot = snapshot, @@ -292,19 +458,26 @@ class NowPlayingWidgetRemoteViewsTest { } } + /** Fails when any edge of the view lies outside [root], on either axis. */ private fun assertFullyInside( root: View, viewId: Int, + where: String = "", ) { val view = root.findViewById(viewId) + val name = "$where ${context.resources.getResourceEntryName(viewId)}".trim() var top = view.top + var left = view.left var parent = view.parent while (parent is View && parent !== root) { top += parent.top + left += parent.left parent = parent.parent } - assertTrue("view $viewId starts above its widget", top >= 0) - assertTrue("view $viewId ends below its widget", top + view.height <= root.height) + assertTrue("$name starts above its widget", top >= 0) + assertTrue("$name ends below its widget", top + view.height <= root.height) + assertTrue("$name starts before its widget", left >= 0) + assertTrue("$name ends past its widget", left + view.width <= root.width) } private fun defaultOptions(): Bundle = Bundle().apply { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt new file mode 100644 index 000000000..ab1e727a4 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetActionHandlerTest.kt @@ -0,0 +1,122 @@ +package cx.aswin.boxlore.feature.widgets + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.feature.widgets.actions.WidgetActionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class WidgetActionHandlerTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val playback = FakePlayback() + private val store = NowPlayingWidgetSnapshotStore(context) + + private class FakePlayback : WidgetPlaybackSource { + override val state = MutableStateFlow(WidgetPlaybackState()) + val calls = mutableListOf() + + override suspend fun restoreBeforeCollect() = Unit + + override suspend fun restoreBeforeAction() { + calls += "restore" + } + + override suspend fun togglePlayPause() { + calls += "toggle" + } + + override suspend fun previous() { + calls += "previous" + } + + override suspend fun next() { + calls += "next" + } + + override suspend fun skipForward() { + calls += "forward" + } + + override suspend fun skipBackward() { + calls += "back" + } + + override suspend fun refreshPosition() { + calls += "refresh" + } + } + + @Before + fun install() { + val fake = playback + NowPlayingWidgetDependenciesHolder.instance = + object : NowPlayingWidgetDependencies { + override val context: Context = this@WidgetActionHandlerTest.context + override val scope: CoroutineScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher()) + override val playback: WidgetPlaybackSource = fake + } + // What the widget last painted: ten seconds in. + store.write(NowPlayingWidgetSnapshot(episodeId = "ep-1", positionMs = 10_000L, durationMs = 3_600_000L, seekForwardMs = 30_000L, seekBackwardMs = 10_000L)) + } + + @After + fun uninstall() { + NowPlayingWidgetDependenciesHolder.instance = null + } + + @Test + fun aSeekIsPreviewedFromThePlayersLivePositionOnTheSameEpisode() = runTest { + playback.state.value = WidgetPlaybackState(episodeId = "ep-1", positionMs = 100_000L, durationMs = 3_600_000L) + + WidgetActionHandler.handle(WidgetControl.SKIP_FORWARD) + + assertEquals(130_000L, store.read()?.positionMs) + } + + @Test + fun aSeekKeepsThePaintedPositionWhenThePlayerIsOnAnotherEpisode() = runTest { + playback.state.value = WidgetPlaybackState(episodeId = "ep-2", positionMs = 100_000L, durationMs = 900_000L) + + WidgetActionHandler.handle(WidgetControl.SKIP_FORWARD) + + assertEquals(40_000L, store.read()?.positionMs) + assertEquals(3_600_000L, store.read()?.durationMs) + } + + @Test + fun everyTransportControlAsksForTheRealPositionOnceAfterItsAction() = runTest { + val actions = + mapOf( + WidgetControl.TOGGLE to "toggle", + WidgetControl.PREVIOUS to "previous", + WidgetControl.NEXT to "next", + WidgetControl.SKIP_BACK to "back", + WidgetControl.SKIP_FORWARD to "forward", + ) + for ((control, action) in actions) { + playback.calls.clear() + WidgetActionHandler.handle(control) + assertEquals("$control", listOf("restore", action, "refresh"), playback.calls) + } + } + + @Test + fun openingTheAppTouchesNeitherThePlayerNorThePosition() = runTest { + WidgetActionHandler.handle(WidgetControl.OPEN_APP) + + assertEquals(emptyList(), playback.calls) + assertEquals(10_000L, store.read()?.positionMs) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt index 82c72295c..c8feff5ba 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetChromeTest.kt @@ -43,16 +43,60 @@ class WidgetChromeTest { assertEquals(0xFF000000.toInt(), chrome.argb(WidgetPalette.primary) and 0xFF000000.toInt()) } + @Test + fun fixedDisplayModeSendsOneColourAndSystemModeSendsLightAndDark() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "dark") + val fixed = WidgetChrome.resolve(context) + assertFalse(fixed.followsSystemDayNight) + + // Display mode "System": the launcher gets both colours, so widgets switch with the phone even + // when the app is not running. + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "system") + val following = WidgetChrome.resolve(context) + assertTrue(following.usesAppTheme) + assertTrue(following.followsSystemDayNight) + + // System appearance leaves light and dark to the launcher's own resources. + writeThemeCache(brand = "violet", appearance = WidgetAppearance.SYSTEM, themeConfig = "system") + assertFalse(WidgetChrome.resolve(context).followsSystemDayNight) + } + + @Test + fun lightAndDarkAppColoursDifferSoTheLauncherHasARealChoice() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "light") + val light = WidgetChrome.resolve(context) + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP, themeConfig = "dark") + val dark = WidgetChrome.resolve(context) + + for (role in listOf(WidgetPalette.surface, WidgetPalette.primaryContainer, WidgetPalette.tertiaryContainer, WidgetPalette.onSurface)) { + assertNotEquals(light.argb(role), dark.argb(role)) + } + } + + @Test + fun everyRoleTheWidgetsPaintWithComesFromTheAppTheme() { + writeThemeCache(brand = "violet", appearance = WidgetAppearance.APP) + val violet = WidgetChrome.resolve(context) + writeThemeCache(brand = "emerald", appearance = WidgetAppearance.APP) + val emerald = WidgetChrome.resolve(context) + + // Tertiary paints New Episodes; if it fell back to the system resource it would not follow the brand. + assertNotEquals(violet.argb(WidgetPalette.tertiaryContainer), emerald.argb(WidgetPalette.tertiaryContainer)) + assertNotEquals(violet.argb(WidgetPalette.tertiary), emerald.argb(WidgetPalette.tertiary)) + assertNotEquals(violet.argb(WidgetPalette.primaryContainer), emerald.argb(WidgetPalette.primaryContainer)) + } + private fun writeThemeCache( brand: String, appearance: String?, + themeConfig: String = "dark", ) { val editor = context .getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) .edit() .putString("theme_brand", brand) - .putString("theme_config", "dark") + .putString("theme_config", themeConfig) .putString("surface_style", "classic_dynamic") .putBoolean("use_dynamic_color", false) if (appearance == null) { diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt new file mode 100644 index 000000000..f4772016f --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetRenderGalleryTest.kt @@ -0,0 +1,268 @@ +package cx.aswin.boxlore.feature.widgets + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.AbsListView +import android.widget.BaseAdapter +import android.widget.FrameLayout +import android.widget.RemoteViews +import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.FontRoundnessAxis +import cx.aswin.boxlore.core.prefs.WidgetAppearance +import cx.aswin.boxlore.core.prefs.WidgetShowsArtwork +import cx.aswin.boxlore.feature.widgets.logic.WidgetSize +import java.io.File +import kotlin.math.roundToInt +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Applies every widget in every state and size the way a launcher would, and draws it. A view id or a + * remote call that does not match the layout fails here instead of leaving a blank widget on a phone. + * + * Set `BOXLORE_WIDGET_RENDERS=1` to also save each drawing under `build/reports/widget-renders/` for + * a visual check against the design. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w412dp-h915dp-420dpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WidgetRenderGalleryTest { + private val context: Context = ApplicationProvider.getApplicationContext() + private val density: Float get() = context.resources.displayMetrics.density + + private class Size(val name: String, val widthDp: Int, val heightDp: Int) + + @Test + fun playbackWidgetsDrawInEveryStateSizeAndTheme() { + val art = artworkFile() + val states = + listOf( + "playing" to playing(art, isPlaying = true, positionMs = 24 * MINUTE, durationMs = 60 * MINUTE), + "paused" to + playing(art, isPlaying = false, positionMs = 14 * MINUTE, durationMs = 86 * MINUTE).copy( + episodeTitle = "The surprisingly long history of the humble paperclip, part two", + podcastTitle = "Paper Trails", + ), + "no-art" to playing(null, isPlaying = true, positionMs = 0L, durationMs = 0L), + "resting" to NowPlayingWidgetSnapshot(), + ) + val sizes = + mapOf( + WidgetVariant.NOW_PLAYING to + listOf(Size("4x2", 350, 182), Size("min", 245, 100), Size("roomy", 380, 220), Size("tall", 350, 278), Size("wide", 530, 182)), + WidgetVariant.BAR to listOf(Size("4x1", 350, 86), Size("min", 245, 48), Size("tallcell", 380, 112), Size("wide", 530, 86)), + WidgetVariant.CONTROLS to listOf(Size("2x2", 170, 182), Size("min", 110, 110), Size("wide", 350, 182)), + WidgetVariant.CONTROLS_NEXT to listOf(Size("2x2", 170, 182)), + ) + val cases = + sizes.flatMap { (variant, variantSizes) -> + variantSizes.flatMap { size -> states.map { (stateName, snapshot) -> PlaybackCase(variant, size, stateName, snapshot) } } + } + for (dark in listOf(false, true)) { + useAppTheme(dark) + cases.forEach { case -> drawPlayback(case, dark) } + } + } + + @Test + fun playbackWidgetsStillFitWhenTheLauncherReportsAStaleSize() { + val art = artworkFile() + val snapshot = + playing(art, isPlaying = true, positionMs = 24 * MINUTE, durationMs = 60 * MINUTE) + .copy(episodeTitle = "The surprisingly long history of the humble paperclip, part two") + useAppTheme(dark = false) + // Reported as a regular size but drawn at another: the launcher picks the layout for the space it has. + val reported = mapOf(WidgetVariant.NOW_PLAYING to Size("r", 350, 182), WidgetVariant.BAR to Size("r", 350, 86), WidgetVariant.CONTROLS to Size("r", 170, 182)) + val drawn = listOf(Size("small", 245, 110), Size("squat", 320, 70), Size("big", 400, 250)) + for ((variant, said) in reported) { + for (actual in drawn) { + val views = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 7, snapshot, options(said), variant, drawn = WidgetSize(actual.widthDp, actual.heightDp)) + save(draw(views, actual, dark = false), "stale-${variant.name.lowercase()}-${actual.name}") + // And the worst case: a launcher that draws the layout picked for the report anyway. + val wrongPick = NowPlayingWidgetRenderer.buildRemoteViewsAt(context, 7, snapshot, options(said), variant) + save(draw(wrongPick, actual, dark = false), "wrongpick-${variant.name.lowercase()}-${actual.name}") + } + } + } + + private class PlaybackCase(val variant: WidgetVariant, val size: Size, val state: String, val snapshot: NowPlayingWidgetSnapshot) + + private fun drawPlayback(case: PlaybackCase, dark: Boolean) { + val views = + NowPlayingWidgetRenderer.buildRemoteViewsAt( + context = context, + appWidgetId = 7, + snapshot = case.snapshot, + options = options(case.size), + variant = case.variant, + ) + save(draw(views, case.size, dark), "${case.variant.name.lowercase()}-${case.size.name}-${case.state}-${themeName(dark)}") + } + + @Test + fun libraryWidgetsDrawWithRowsTilesAndRestingStates() { + val art = artworkFile().absolutePath + val full = + LibraryWidgetSnapshot( + subscriptions = + (1..14).map { index -> + WidgetShowRow( + podcastId = "show-$index", + title = "Show $index", + // Every third show has no cover yet, so placeholders are drawn too. + artworkCachePath = art.takeIf { index % 3 != 0 }, + deepLinkUri = "boxlore://podcast/show-$index", + isNew = index % 4 == 1, + ) + }, + newEpisodes = + listOf("Why curiosity compounds" to "The Daily Lore", "A quiet week in Kyoto" to "Field Notes", "How maps shape memory" to "Soft Power", "The last night bus" to "Night Shift Radio", "Sourdough, slowly" to "Small Batch") + .mapIndexed { index, (episode, show) -> + WidgetEpisodeRow( + episodeId = "episode-$index", + episodeTitle = episode, + podcastId = "show-$index", + podcastTitle = show, + artworkCachePath = art.takeIf { index != 2 }, + deepLinkUri = "boxlore://episode/episode-$index", + ) + }, + counts = WidgetLibraryCounts(subscriptions = 24, newEpisodes = 12), + ) + val sizes = listOf(Size("4x3", 350, 278), Size("narrow", 260, 278), Size("tall", 350, 374)) + // New Episodes has one look; Your Shows is drawn as whole covers and as mixed shapes. + val looks = + listOf( + LibraryLook(LibraryWidgetKind.NEW_EPISODES, WidgetShowsArtwork.ROUNDED, "new_episodes"), + LibraryLook(LibraryWidgetKind.SUBSCRIPTIONS, WidgetShowsArtwork.ROUNDED, "subscriptions-covers"), + LibraryLook(LibraryWidgetKind.SUBSCRIPTIONS, WidgetShowsArtwork.SHAPES, "subscriptions-shapes"), + ) + for (dark in listOf(false, true)) { + useAppTheme(dark) + for (look in looks) { + setShowsArtwork(look.artwork) + sizes.forEach { size -> save(drawLibrary(look.kind, full, size, dark), "${look.name}-${size.name}-${themeName(dark)}") } + } + LibraryWidgetKind.entries.forEach { kind -> + save(drawLibrary(kind, LibraryWidgetSnapshot(), sizes.first(), dark), "${kind.name.lowercase()}-resting-${themeName(dark)}") + } + } + } + + private class LibraryLook(val kind: LibraryWidgetKind, val artwork: String, val name: String) + + private fun drawLibrary(kind: LibraryWidgetKind, snapshot: LibraryWidgetSnapshot, size: Size, dark: Boolean): Bitmap { + LibraryWidgetSnapshotStore(context).write(snapshot) + val views = LibraryWidgetRenderer.build(context, appWidgetId = 9, snapshot = snapshot, kind = kind) + val factory = LibraryWidgetRemoteViewsFactory(context, kind).apply { onDataSetChanged() } + return draw(views, size, dark) { root -> + // A launcher binds the collection through the remote service; here the factory feeds it directly. + val collection = root.findViewById(R.id.widget_list_view) + collection.emptyView = root.findViewById(R.id.widget_empty_container) + collection.adapter = + object : BaseAdapter() { + override fun getCount(): Int = factory.count + + override fun getItem(position: Int): Any = position + + override fun getItemId(position: Int): Long = factory.getItemId(position) + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View = factory.getViewAt(position).apply(context, parent) + } + } + } + + private fun draw(views: RemoteViews, size: Size, dark: Boolean, prepare: (View) -> Unit = {}): Bitmap { + val width = (size.widthDp * density).roundToInt() + val height = (size.heightDp * density).roundToInt() + val host = FrameLayout(context) + val root = views.apply(context, host) + host.addView(root, FrameLayout.LayoutParams(width, height)) + prepare(root) + host.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY), + ) + host.layout(0, 0, width, height) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + // A stand-in wallpaper, so the gaps between shapes read as on a home screen. + drawColor(if (dark) Color.rgb(12, 13, 19) else Color.rgb(205, 208, 232)) + host.draw(this) + } + assertTrue("the widget drew something", bitmap.width > 0 && bitmap.height > 0) + return bitmap + } + + private fun save(bitmap: Bitmap, name: String) { + if (System.getenv("BOXLORE_WIDGET_RENDERS") != "1") return + val directory = File("build/reports/widget-renders").apply { mkdirs() } + File(directory, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + + private fun options(size: Size): Bundle = Bundle().apply { + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, size.widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, size.widthDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, size.heightDp) + putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, size.heightDp) + } + + private fun playing(art: File?, isPlaying: Boolean, positionMs: Long, durationMs: Long) = NowPlayingWidgetSnapshot( + episodeId = "episode-1", + episodeTitle = "Why curiosity compounds", + podcastTitle = "The Daily Lore", + artworkCachePath = art?.absolutePath, + isPlaying = isPlaying, + positionMs = positionMs, + durationMs = durationMs, + seekForwardMs = 30_000L, + seekBackwardMs = 10_000L, + ) + + /** A flat cover with a sun, like the stand-in art on the design boards. */ + private fun artworkFile(): File { + val file = File(context.cacheDir, "gallery-cover.png") + val bitmap = Bitmap.createBitmap(COVER_PX, COVER_PX, Bitmap.Config.ARGB_8888) + Canvas(bitmap).apply { + drawColor(Color.rgb(216, 73, 42)) + drawCircle(COVER_PX * 0.66f, COVER_PX * 0.36f, COVER_PX * 0.22f, Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.rgb(255, 226, 168) }) + } + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + return file + } + + private fun themeName(dark: Boolean): String = if (dark) "dark" else "light" + + private fun useAppTheme(dark: Boolean) { + themeCache() + .edit() + .putString("theme_brand", "violet") + .putString("theme_config", if (dark) "dark" else "light") + .putString("surface_style", "classic_dynamic") + .putBoolean("use_dynamic_color", false) + .putString(WidgetAppearance.PREF_KEY, WidgetAppearance.APP) + .commit() + } + + private fun setShowsArtwork(style: String) { + themeCache().edit().putString(WidgetShowsArtwork.PREF_KEY, style).commit() + } + + private fun themeCache() = context.getSharedPreferences(FontRoundnessAxis.THEME_FAST_CACHE, Context.MODE_PRIVATE) + + private companion object { + const val MINUTE = 60_000L + const val COVER_PX = 256 + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt new file mode 100644 index 000000000..08a71032c --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetShapesTest.kt @@ -0,0 +1,66 @@ +package cx.aswin.boxlore.feature.widgets + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetShapesTest { + @Test + fun anUnstartedEpisodeShowsOnlyTheFullTrack() { + val arcs = WidgetRingArcs.of(fraction = 0f, gapDegrees = 9f) + assertNull(arcs.played) + assertEquals(WidgetRingArcs.Arc(0f, 360f), arcs.remaining) + } + + @Test + fun thePlayedArcAndTheRestAreSeparatedByAGapAtBothEnds() { + val gap = 9f + val arcs = WidgetRingArcs.of(fraction = 0.4f, gapDegrees = gap) + val played = arcs.played!! + val remaining = arcs.remaining!! + + assertEquals(144f, played.sweepDegrees, 0.01f) + assertEquals(played.sweepDegrees + gap, remaining.startDegrees, 0.01f) + // The rest stops one gap short of where the played arc began. + assertEquals(360f - gap, remaining.startDegrees + remaining.sweepDegrees, 0.01f) + } + + @Test + fun aSliverOfProgressStillShowsAndTheEndClosesTheRing() { + val started = WidgetRingArcs.of(fraction = 0.0005f, gapDegrees = 9f) + assertTrue(started.played!!.sweepDegrees >= 2f) + assertNotNull(started.remaining) + + val nearlyDone = WidgetRingArcs.of(fraction = 0.99f, gapDegrees = 9f) + assertEquals(WidgetRingArcs.Arc(0f, 360f), nearlyDone.played) + assertNull(nearlyDone.remaining) + } + + @Test + fun badInputNeverDrawsOutsideTheRing() { + assertNull(WidgetRingArcs.of(Float.NaN, 9f).played) + assertEquals(WidgetRingArcs.Arc(0f, 360f), WidgetRingArcs.of(7f, 9f).played) + assertNull(WidgetRingArcs.of(-3f, 9f).played) + } + + @Test + fun mixedShapesCycleAndNeverPutTheSameShapeSideBySide() { + val firstRow = (0 until 4).map(WidgetShapes::mixedShapeAt) + assertEquals(4, firstRow.toSet().size) + for (position in 0 until 48) { + assertTrue( + "tiles $position and ${position + 1} share a shape", + WidgetShapes.mixedShapeAt(position) != WidgetShapes.mixedShapeAt(position + 1), + ) + } + assertEquals(WidgetShapes.mixedShapeAt(0), WidgetShapes.mixedShapeAt(12)) + } + + @Test + fun wholeCoversAreTheDefaultTileAndShapesAreOptIn() { + assertEquals(WidgetArtShape.ROUNDED, LibraryWidgetRemoteViewsFactory.tileShape(mixedShapes = false, position = 3)) + assertEquals(WidgetShapes.mixedShapeAt(3), LibraryWidgetRemoteViewsFactory.tileShape(mixedShapes = true, position = 3)) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt deleted file mode 100644 index 0a15e761f..000000000 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/WidgetTextBitmapRendererTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package cx.aswin.boxlore.feature.widgets - -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [31]) -class WidgetTextBitmapRendererTest { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Test - fun renderedLabelHasExactBoundsAndVisiblePixels() { - val bitmap = - WidgetTextBitmapRenderer.render( - context = context, - spec = - WidgetTextBitmapRenderer.Spec( - text = "A rounded episode title", - widthDp = 180, - heightDp = 48, - preferredSizeSp = 22f, - minSizeSp = 14f, - weight = 600, - maxLines = 2, - ), - colorRes = R.color.widget_on_surface, - ) - val density = context.resources.displayMetrics.density - - assertEquals((180 * density).toInt(), bitmap.width) - assertEquals((48 * density).toInt(), bitmap.height) - } -} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt new file mode 100644 index 000000000..3a9d5f009 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/ArtworkTimeoutCooldownTest.kt @@ -0,0 +1,36 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ArtworkTimeoutCooldownTest { + private var now = 1_000L + private val cooldown = ArtworkTimeoutCooldown(cooldownMs = 60_000L, nowMs = { now }) + private val cover = "https://example.com/cover.jpg" + + @Test + fun aCoverThatTimedOutIsNotWaitedForAgainUntilTheCooldownEnds() { + assertFalse(cooldown.isCoolingDown(cover, emptyList())) + + cooldown.recordTimeout(cover, emptyList()) + assertTrue(cooldown.isCoolingDown(cover, emptyList())) + + now += 59_999L + assertTrue(cooldown.isCoolingDown(cover, emptyList())) + + now += 1L + assertFalse("the next row may try it again", cooldown.isCoolingDown(cover, emptyList())) + } + + @Test + fun theFallbacksArePartOfTheRequestAndOtherCoversAreNotHeldBack() { + cooldown.recordTimeout(cover, listOf("https://example.com/show.jpg")) + + assertTrue(cooldown.isCoolingDown(cover, listOf("https://example.com/show.jpg"))) + assertFalse("another fallback is another attempt", cooldown.isCoolingDown(cover, listOf("https://example.com/other.jpg"))) + assertFalse(cooldown.isCoolingDown(cover, emptyList())) + assertFalse(cooldown.isCoolingDown("https://example.com/another.jpg", listOf("https://example.com/show.jpg"))) + assertFalse(cooldown.isCoolingDown(null, emptyList())) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt new file mode 100644 index 000000000..564560d97 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/LibraryArtworkBatchesTest.kt @@ -0,0 +1,110 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import cx.aswin.boxlore.feature.widgets.LibraryWidgetSnapshot +import cx.aswin.boxlore.feature.widgets.WidgetEpisodeRow +import cx.aswin.boxlore.feature.widgets.WidgetShowRow +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LibraryArtworkBatchesTest { + private fun show(index: Int, url: String? = "https://example.com/show-$index.jpg", cached: String? = null) = + WidgetShowRow(podcastId = "show-$index", title = "Show $index", artworkUrl = url, artworkCachePath = cached, deepLinkUri = "boxlore://podcast/show-$index") + + private fun episode(index: Int) = WidgetEpisodeRow( + episodeId = "episode-$index", + episodeTitle = "Episode $index", + podcastId = "show-$index", + podcastTitle = "Show $index", + artworkUrl = "https://example.com/episode-$index.jpg", + deepLinkUri = "boxlore://episode/episode-$index", + ) + + private val pathFor: suspend (String?, List) -> String? = { url, _ -> "/cache/${url!!.substringAfterLast('/')}" } + + @Test + fun fourCoversArePublishedTogetherAndAFifthOnItsOwn() = runTest { + val four = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(4) { show(it) }), load = pathFor) { four += it } + assertEquals(1, four.size) + assertTrue(four.single().subscriptions.all { it.artworkCachePath != null }) + + val five = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(5) { show(it) }), load = pathFor) { five += it } + assertEquals(2, five.size) + assertEquals(4, five[0].subscriptions.count { it.artworkCachePath != null }) + assertEquals(5, five[1].subscriptions.count { it.artworkCachePath != null }) + } + + @Test + fun showsAndEpisodesShareOneCountAndEachPublishHoldsEveryCoverSoFar() = runTest { + val published = mutableListOf() + val snapshot = LibraryWidgetSnapshot(subscriptions = List(3) { show(it) }, newEpisodes = List(3) { episode(it) }) + + LibraryArtworkBatches.load(snapshot, load = pathFor) { published += it } + + assertEquals(2, published.size) + assertEquals(3 to 1, published[0].subscriptions.count { it.artworkCachePath != null } to published[0].newEpisodes.count { it.artworkCachePath != null }) + assertEquals(3 to 3, published[1].subscriptions.count { it.artworkCachePath != null } to published[1].newEpisodes.count { it.artworkCachePath != null }) + assertEquals("/cache/episode-2.jpg", published[1].newEpisodes[2].artworkCachePath) + } + + @Test + fun coversThatCannotBeLoadedAreSkippedAndRowsWithACoverOrNoAddressAreNotLoaded() = runTest { + val asked = mutableListOf() + val published = mutableListOf() + val snapshot = + LibraryWidgetSnapshot( + subscriptions = listOf(show(0), show(1, cached = "/cache/already.jpg"), show(2, url = null), show(3)), + ) + + LibraryArtworkBatches.load( + snapshot, + load = { url, _ -> + asked += url + if (url!!.endsWith("show-0.jpg")) null else "/cache/show-3.jpg" + }, + ) { published += it } + + assertEquals(listOf("https://example.com/show-0.jpg", "https://example.com/show-3.jpg"), asked) + assertEquals(1, published.size) + assertEquals(listOf(null, "/cache/already.jpg", null, "/cache/show-3.jpg"), published.single().subscriptions.map { it.artworkCachePath }) + + // Nothing loaded means nothing to publish. + val none = mutableListOf() + LibraryArtworkBatches.load(LibraryWidgetSnapshot(subscriptions = List(3) { show(it) }), load = { _, _ -> null }) { none += it } + assertTrue(none.isEmpty()) + } + + @Test + fun aCancelledLoadPublishesNothingMore() = runTest { + val published = mutableListOf() + val sixthAsked = CompletableDeferred() + var asked = 0 + val job = + launch { + LibraryArtworkBatches.load( + LibraryWidgetSnapshot(subscriptions = List(8) { show(it) }), + load = { url, fallbacks -> + if (++asked == 6) { + sixthAsked.complete(Unit) + // Never answers: the newer library cancels this load here. + CompletableDeferred().await() + } else { + pathFor(url, fallbacks) + } + }, + ) { published += it } + } + sixthAsked.await() + job.cancelAndJoin() + + // The first four went out; the fifth was loaded but never published once the load was cancelled. + assertEquals(1, published.size) + assertEquals(4, published.single().subscriptions.count { it.artworkCachePath != null }) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt index 609b398f2..304221493 100644 --- a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/NowPlayingWidgetLogicTest.kt @@ -97,4 +97,55 @@ class NowPlayingWidgetLogicTest { assertEquals(25_000L, back.positionMs) assertEquals(70_000L, forward.positionMs) } + + @Test + fun `update policy repaints when the ring or the time left moves, not on every tick`() { + val minute = 60_000L + val base = + NowPlayingWidgetSnapshot( + episodeId = "ep-1", + episodeTitle = "Episode", + podcastTitle = "Show", + isPlaying = true, + positionMs = 10 * minute, + durationMs = 60 * minute, + ) + + // Half a second later nothing a listener can see has changed. + assertFalse(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs + 500L))) + // A minute later the label counts down. + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs + minute))) + // Seeking back repaints too. + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(positionMs = base.positionMs - 2 * minute))) + // The length arriving late turns the ring on. + assertTrue(WidgetUpdatePolicy.shouldRender(base.copy(durationMs = 0L), base)) + } + + @Test + fun `update policy repaints when the seek lengths change, since the icons show them`() { + val base = NowPlayingWidgetSnapshot(episodeId = "ep-1", seekForwardMs = 30_000L, seekBackwardMs = 10_000L) + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(seekForwardMs = 45_000L))) + assertTrue(WidgetUpdatePolicy.shouldRender(base, base.copy(seekBackwardMs = 15_000L))) + assertFalse(WidgetUpdatePolicy.shouldRender(base, base.copy())) + } + + @Test + fun `content description says how long is left`() { + val minute = 60_000L + val snapshot = + NowPlayingWidgetSnapshot( + episodeId = "ep-1", + episodeTitle = "Episode", + podcastTitle = "Show", + isPlaying = true, + positionMs = 24 * minute, + durationMs = 60 * minute, + ) + assertEquals("Episode from Show, playing, 36 minutes left", WidgetSemantics.contentDescription(snapshot)) + assertEquals( + "Episode from Show, paused, 1 minute left", + WidgetSemantics.contentDescription(snapshot.copy(isPlaying = false, positionMs = 59 * minute + 30_000L)), + ) + assertEquals("Episode from Show, playing", WidgetSemantics.contentDescription(snapshot.copy(durationMs = 0L))) + } } diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt new file mode 100644 index 000000000..ca2d1c195 --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetPresentationTest.kt @@ -0,0 +1,105 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetPresentationTest { + private fun full(heightDp: Int, widthDp: Int = 350) = WidgetPresentation.fullControls(WidgetSize(widthDp, heightDp)) + + private fun bar(heightDp: Int, widthDp: Int = 350) = WidgetPresentation.bar(WidgetSize(widthDp, heightDp)) + + @Test + fun fullControlsDropsTheShowNameFirstThenTheTimeAndNeverTheTitle() { + // A roomy widget shows everything. + assertEquals(WidgetPresentation.Meta.TIME_AND_SHOW, full(182).meta) + assertEquals(2, full(182).titleLines) + // A shorter one keeps the title and the time left. + assertEquals(WidgetPresentation.Meta.TIME, full(110).meta) + assertEquals(1, full(110).titleLines) + // The shortest keeps only the episode title. + assertEquals(WidgetPresentation.Meta.NONE, full(80).meta) + assertEquals(1, full(80).titleLines) + } + + @Test + fun fullControlsNeverGainsLinesAsItShrinksAndNeverLosesThemAsItGrows() { + for (width in listOf(245, 350, 620)) { + var lines = Int.MAX_VALUE + var meta = WidgetPresentation.Meta.TIME_AND_SHOW.ordinal + for (height in 420 downTo 60) { + val show = full(height, width) + assertTrue("title lines grew while shrinking to ${width}x$height", show.titleLines <= lines) + assertTrue("the line under the title grew while shrinking to ${width}x$height", show.meta.ordinal >= meta) + assertTrue(show.titleLines >= 1) + lines = show.titleLines + meta = show.meta.ordinal + } + } + } + + @Test + fun fullControlsChangesShapeOnTheWayUpAndBackDown() { + val regular = full(182) + val tall = full(278) + + assertFalse(regular.tall) + assertTrue(tall.tall) + assertEquals(3, tall.titleLines) + // Resizing back gives the same answer as before. + assertEquals(regular, full(182)) + } + + @Test + fun aNarrowFullControlsWidgetOnlyShowsWhatItsSmallerFrameHasRoomFor() { + // In a narrow, tall cell the frame stops at 60% of the width, so the cell's height adds nothing. + assertEquals(full(147, widthDp = 245), full(220, widthDp = 245)) + assertEquals(1, full(220, widthDp = 245).titleLines) + assertEquals(2, full(220, widthDp = 350).titleLines) + } + + @Test + fun compactPlayerShowsTheTimeLeftOnlyWithRoom() { + val slim = bar(52) + val regular = bar(86) + + assertEquals(1, slim.titleLines) + assertFalse(slim.showTimeLeft) + assertEquals(2, regular.titleLines) + assertTrue(regular.showTimeLeft) + assertEquals(regular, bar(86)) + } + + @Test + fun compactPlayerStaysSlimInTallAndNarrowCells() { + // The pill is never taller than a quarter of its width, however tall the cell is. + assertEquals(bar(87), bar(160)) + assertEquals(bar(61, widthDp = 245), bar(160, widthDp = 245)) + assertFalse("a narrow pill has no room for the time left", bar(160, widthDp = 245).showTimeLeft) + } + + @Test + fun everySizeStepShowsExactlyWhatAWidgetOfAnySizeInItsRangeShows() { + // The launcher draws the largest step that fits, so that step must stand for every size it covers. + for (width in 100..700 step 7) { + for (height in 40..420 step 5) { + val drawn = WidgetSize(width, height) + val fullStep = WidgetPresentation.stepFor(WidgetPresentation.FULL_CONTROLS_STEPS, drawn) + assertEquals("full controls at ${width}x$height", WidgetPresentation.fullControls(drawn), WidgetPresentation.fullControls(fullStep)) + val barStep = WidgetPresentation.stepFor(WidgetPresentation.BAR_STEPS, drawn) + assertEquals("compact at ${width}x$height", WidgetPresentation.bar(drawn), WidgetPresentation.bar(barStep)) + } + } + } + + @Test + fun sizeStepsGrowOneInsideTheNextSoTheLargestThatFitsIsAlwaysTheClosest() { + for (steps in listOf(WidgetPresentation.FULL_CONTROLS_STEPS, WidgetPresentation.BAR_STEPS)) { + assertEquals(WidgetSize(0, 0), steps.first()) + steps.zipWithNext { smaller, larger -> + assertTrue("$smaller fits inside $larger", smaller.widthDp <= larger.widthDp && smaller.heightDp <= larger.heightDp && smaller != larger) + } + } + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt new file mode 100644 index 000000000..8a1d72b3f --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressPollerTest.kt @@ -0,0 +1,56 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetProgressPollerTest { + private val interval = 20_000L + + @Test + fun asksForThePositionAtEachIntervalOnlyWhilePlaying() = runTest { + val playing = MutableStateFlow(false) + var refreshes = 0 + val job = launch { WidgetProgressPoller.run(playing, interval, hasWidgets = { true }) { refreshes++ } } + + advanceTimeBy(interval * 3) + assertEquals("paused: nothing to refresh", 0, refreshes) + + playing.value = true + runCurrent() + advanceTimeBy(interval * 3 + 1) + assertEquals(3, refreshes) + + // Pausing stops the polling straight away, and playing again starts a fresh interval. + playing.value = false + advanceTimeBy(interval * 5) + assertEquals(3, refreshes) + playing.value = true + runCurrent() + advanceTimeBy(interval + 1) + assertEquals(4, refreshes) + job.cancel() + } + + @Test + fun doesNotTouchThePlayerWhileNoPlaybackWidgetIsPlaced() = runTest { + var placed = false + var refreshes = 0 + val job = launch { WidgetProgressPoller.run(MutableStateFlow(true), interval, hasWidgets = { placed }) { refreshes++ } } + + advanceTimeBy(interval * 4 + 1) + assertEquals(0, refreshes) + + // Placing a widget is picked up at the next interval, with no restart needed. + placed = true + advanceTimeBy(interval) + assertEquals(1, refreshes) + job.cancel() + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt new file mode 100644 index 000000000..5c550829f --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetProgressTest.kt @@ -0,0 +1,44 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WidgetProgressTest { + private val minute = 60_000L + + @Test + fun fractionIsZeroUntilTheLengthIsKnownAndNeverLeavesItsRange() { + assertEquals(0f, WidgetProgress.fraction(positionMs = 5 * minute, durationMs = 0L), 0f) + assertEquals(0.25f, WidgetProgress.fraction(15 * minute, 60 * minute), 0.0001f) + assertEquals(1f, WidgetProgress.fraction(90 * minute, 60 * minute), 0f) + assertEquals(0f, WidgetProgress.fraction(-1L, 60 * minute), 0f) + } + + @Test + fun minutesLeftRoundsUpSoItOnlyReachesZeroAtTheEnd() { + assertNull(WidgetProgress.minutesLeft(0L, 0L)) + assertEquals(60, WidgetProgress.minutesLeft(0L, 60 * minute)) + assertEquals(36, WidgetProgress.minutesLeft(24 * minute, 60 * minute)) + assertEquals(1, WidgetProgress.minutesLeft(60 * minute - 1_000L, 60 * minute)) + assertEquals(0, WidgetProgress.minutesLeft(60 * minute, 60 * minute)) + } + + @Test + fun timeLeftSplitsIntoHoursAndMinutesAndDisappearsWhenNothingIsLeft() { + assertEquals(WidgetProgress.TimeLeft(hours = 0, minutes = 36), WidgetProgress.timeLeft(24 * minute, 60 * minute)) + assertEquals(WidgetProgress.TimeLeft(hours = 1, minutes = 12), WidgetProgress.timeLeft(14 * minute, 86 * minute)) + assertEquals(WidgetProgress.TimeLeft(hours = 2, minutes = 0), WidgetProgress.timeLeft(0L, 120 * minute)) + assertNull(WidgetProgress.timeLeft(60 * minute, 60 * minute)) + assertNull(WidgetProgress.timeLeft(10 * minute, 0L)) + } + + @Test + fun ringStepsAreCoarseEnoughToIgnoreSecondBySecondTicks() { + val duration = 60 * minute + val step = WidgetProgress.ringStep(10 * minute, duration) + assertEquals(step, WidgetProgress.ringStep(10 * minute + 500L, duration)) + assertEquals(step + 1, WidgetProgress.ringStep(10 * minute + 30_000L, duration)) + assertEquals(WidgetProgress.RING_STEPS, WidgetProgress.ringStep(duration, duration)) + } +} diff --git a/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt new file mode 100644 index 000000000..9881bbbef --- /dev/null +++ b/feature/widgets/src/test/java/cx/aswin/boxlore/feature/widgets/logic/WidgetSizesTest.kt @@ -0,0 +1,36 @@ +package cx.aswin.boxlore.feature.widgets.logic + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WidgetSizesTest { + private val fallback = WidgetSize(250, 110) + + @Test + fun portraitIsNarrowAndTallLandscapeIsWideAndShort() { + val sizes = WidgetSizes.of(minWidthDp = 350, maxWidthDp = 720, minHeightDp = 120, maxHeightDp = 182, fallback = fallback) + assertEquals(WidgetSize(350, 182), sizes.portrait) + assertEquals(WidgetSize(720, 120), sizes.landscape) + // Pictures are drawn for the most space the widget may get. + assertEquals(WidgetSize(720, 182), sizes.largest) + assertEquals(182, sizes.largest.shortSide) + } + + @Test + fun missingValuesFallBackInsteadOfCollapsingTheWidget() { + val nothing = WidgetSizes.of(0, 0, 0, 0, fallback) + assertEquals(fallback, nothing.portrait) + assertEquals(fallback, nothing.landscape) + + val onlyMinimums = WidgetSizes.of(minWidthDp = 300, maxWidthDp = 0, minHeightDp = 90, maxHeightDp = 0, fallback = fallback) + assertEquals(WidgetSize(300, 90), onlyMinimums.portrait) + assertEquals(WidgetSize(300, 90), onlyMinimums.landscape) + } + + @Test + fun swappedReportsAreStillReadTheRightWayRound() { + val sizes = WidgetSizes.of(minWidthDp = 720, maxWidthDp = 350, minHeightDp = 182, maxHeightDp = 120, fallback = fallback) + assertEquals(WidgetSize(350, 182), sizes.portrait) + assertEquals(WidgetSize(720, 120), sizes.landscape) + } +} diff --git a/release-notes/upcoming.md b/release-notes/upcoming.md index 32984030e..bb43fcb53 100644 --- a/release-notes/upcoming.md +++ b/release-notes/upcoming.md @@ -26,8 +26,12 @@ Announcement title: ## Improved +- Home screen widgets have a new look with rounder shapes. The players now show how far you are and how much time is left, and the skip buttons show your own skip lengths. +- The Your Shows widget is now a grid of artwork. - On Home, a show you listen to oldest first now picks up exactly where you left off, even deep into a long series, and skips trailers. Daily mix suggests the same next episode. ## Fixed +- Widgets that follow your phone's light and dark mode now switch with it, even when boxlore isn't open. +- The Your Shows and New Episodes widgets now count your whole library instead of stopping at 50. - On Home, a show you've heard every episode of now lists its latest episodes instead of an empty list, and a show whose episodes can't load lets you try again.