diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 818eb6e5..7c43811e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -272,6 +272,7 @@ dependencies {
// WorkManager (background downloads, foreground-service notification)
implementation(libs.androidx.work.runtime.ktx)
+ implementation(libs.androidx.glance.appwidget)
// Play Billing (one-time unlock)
implementation(libs.billing.ktx)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2116e661..e7d30d7b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -85,6 +85,19 @@
+
+
+
+
+
+
+
+
navController.navigate(Routes.bookDetail(route.bookId)) {
- launchSingleTop = true
+ is WidgetRoute.Book -> {
+ val top = navController.currentBackStackEntry
+ if (opensNewBookPage(top?.destination?.route, top?.arguments?.getString("bookId"), route.bookId)) {
+ navController.navigate(Routes.bookDetail(route.bookId))
+ }
}
WidgetRoute.Unlock -> navController.navigate(Routes.UNLOCK) {
launchSingleTop = true
diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt
index 13b16605..86715197 100644
--- a/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt
@@ -132,7 +132,7 @@ class HomeViewModel @Inject constructor(
if (libraryId != null && canShowHomeBooks(settings, hasAuthToken)) {
// The recent-books flow re-emits on any AudioBooks change, so
// a folder import refreshes the book count along with it.
- audioBookDao.observeRecentlyPlayedByLibrary(libraryId, 9)
+ audioBookDao.observeRecentlyPlayedByLibrary(libraryId, RECENT_LIVES_LIMIT)
.collect { results -> processRecentlyPlayed(results, libraryId) }
} else {
processRecentlyPlayed(emptyList(), libraryId = null)
@@ -154,7 +154,7 @@ class HomeViewModel @Inject constructor(
)
}
val results = if (visibleLibraryId != null) {
- audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, 9)
+ audioBookDao.getRecentlyPlayedByLibrary(visibleLibraryId, RECENT_LIVES_LIMIT)
} else {
emptyList()
}
@@ -243,7 +243,7 @@ class HomeViewModel @Inject constructor(
}
// Aggregate total listening time
- val totalSeconds = results.sumOf { it.audioBook.currentTimeSeconds }
+ val totalSeconds = totalTimeGivenSeconds(results.map { it.audioBook.currentTimeSeconds })
val totalText = formatListeningTime(totalSeconds)
// Counted only when Home is empty. A failed count reads as no books,
@@ -303,17 +303,6 @@ class HomeViewModel @Inject constructor(
// ─── Time Formatting ─────────────────────────────────────────────────────
- /** Formats seconds as compact listening time: "< 1m", "45m", "3h 22m" */
- private fun formatListeningTime(totalSeconds: Double): String {
- val totalMinutes = totalSeconds / 60.0
- if (totalMinutes < 1) return "< 1m"
- val totalHours = totalSeconds / 3600.0
- if (totalHours < 1) return "${totalMinutes.toInt()}m"
- val hours = totalHours.toInt()
- val mins = ((totalSeconds % 3600) / 60).toInt()
- return "${hours}h ${mins}m"
- }
-
/** Formats an ISO-8601 timestamp as relative time: "3h ago", "Yesterday", etc. */
private fun formatRelativeTime(timestamp: String?): String {
if (timestamp.isNullOrEmpty()) return ""
diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt b/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt
new file mode 100644
index 00000000..cc1b2c5e
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt
@@ -0,0 +1,26 @@
+package com.ninelivesaudio.app.ui.home
+
+/*
+ * The altar card's numbers, shared by Home and the Nine Lives home screen
+ * widget so the two always say the same thing.
+ */
+
+/** How many recent books Home shows as Lives, and sums into "Total time given". */
+internal const val RECENT_LIVES_LIMIT = 9
+
+/** Formats seconds as compact listening time: "< 1m", "45m", "3h 22m". */
+internal fun formatListeningTime(totalSeconds: Double): String {
+ val totalMinutes = totalSeconds / 60.0
+ if (totalMinutes < 1) return "< 1m"
+ val totalHours = totalSeconds / 3600.0
+ if (totalHours < 1) return "${totalMinutes.toInt()}m"
+ val hours = totalHours.toInt()
+ val mins = ((totalSeconds % 3600) / 60).toInt()
+ return "${hours}h ${mins}m"
+}
+
+/**
+ * "Total time given": the saved positions of the recent Lives, added up.
+ * This is Home's long standing math, not lifetime listening.
+ */
+internal fun totalTimeGivenSeconds(currentTimesSeconds: List): Double = currentTimesSeconds.sum()
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt
index 720b52df..762285d3 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetActionActivity.kt
@@ -43,6 +43,7 @@ class WidgetActionActivity : ComponentActivity() {
val (bookId, tap) = when (launch) {
is WidgetLaunch.PlayBook -> launch.bookId to widgetPlayback.binderTap(launch)
is WidgetLaunch.OpenBook -> launch.bookId to widgetPlayback.binderTap(launch)
+ is WidgetLaunch.TogglePlay -> launch.bookId to widgetPlayback.toggleTap(launch)
null, WidgetLaunch.Nothing -> {
finish()
return
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt
index 39da26c7..f5bd2ce7 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetCovers.kt
@@ -18,7 +18,7 @@ internal const val BINDER_COVER_PX = 192
internal const val HOME_CURRENT_COVER_PX = 192
/** One of the large widget's next Lives tiles (W2). */
-internal const val HOME_LIFE_COVER_PX = 96
+internal const val HOME_LIFE_COVER_PX = 128
/** Bitmap bytes one RemoteViews payload may carry, well under the 1 MB transaction. */
internal const val WIDGET_BITMAP_BUDGET_BYTES = 600 * 1024
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt
index a55cbbb4..1335f370 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt
@@ -14,6 +14,9 @@ internal sealed interface WidgetLaunch {
data class PlayBook(val bookId: String) : WidgetLaunch
data object OpenUnlock : WidgetLaunch
+ /** The large widget's play or pause button, for the book it shows. */
+ data class TogglePlay(val bookId: String) : WidgetLaunch
+
/** A tap on an empty sleeve. The trampoline finishes without launching anything. */
data object Nothing : WidgetLaunch
}
@@ -27,12 +30,14 @@ internal fun WidgetLaunch.actionName(): String = when (this) {
is WidgetLaunch.PlayBook -> "play_book"
WidgetLaunch.OpenUnlock -> "open_unlock"
WidgetLaunch.Nothing -> "empty"
+ is WidgetLaunch.TogglePlay -> "toggle_play"
}
internal val WidgetLaunch.bookIdOrNull: String?
get() = when (this) {
is WidgetLaunch.OpenBook -> bookId
is WidgetLaunch.PlayBook -> bookId
+ is WidgetLaunch.TogglePlay -> bookId
else -> null
}
@@ -45,10 +50,19 @@ internal fun parseWidgetLaunch(action: String?, bookId: String?): WidgetLaunch?
"empty" -> WidgetLaunch.Nothing
"open_book" -> id?.let { WidgetLaunch.OpenBook(it) }
"play_book" -> id?.let { WidgetLaunch.PlayBook(it) }
+ "toggle_play" -> id?.let { WidgetLaunch.TogglePlay(it) }
else -> null
}
}
+/**
+ * Whether a widget's Open tap needs a new book page. Only the same book
+ * already on top is left alone. Navigation's single top matches the route
+ * pattern, not the book, so it would keep another book's page instead.
+ */
+internal fun opensNewBookPage(topRoute: String?, topBookId: String?, bookId: String): Boolean =
+ !(topRoute == com.ninelivesaudio.app.ui.navigation.Routes.BOOK_DETAIL && topBookId == bookId)
+
/** The screen `MainActivity` opens for a widget launch. */
internal sealed interface WidgetRoute {
data class Book(val bookId: String) : WidgetRoute
@@ -65,6 +79,7 @@ internal fun widgetRoute(launch: WidgetLaunch): WidgetRoute? = when (launch) {
is WidgetLaunch.OpenBook -> WidgetRoute.Book(launch.bookId)
WidgetLaunch.OpenUnlock -> WidgetRoute.Unlock
is WidgetLaunch.PlayBook -> null
+ is WidgetLaunch.TogglePlay -> null
}
internal fun Intent.putWidgetLaunch(launch: WidgetLaunch): Intent = apply {
@@ -81,6 +96,16 @@ internal fun widgetMainIntent(context: Context, launch: WidgetLaunch): Intent =
.putWidgetLaunch(launch)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+/**
+ * A tap the trampoline decides (Play, Open its page, play or pause), as a
+ * finished intent. The data URI keeps each launch's PendingIntent its own,
+ * since Android ignores extras when it matches PendingIntents.
+ */
+internal fun widgetActionIntent(context: Context, launch: WidgetLaunch): Intent =
+ widgetActionIntent(context)
+ .putWidgetLaunch(launch)
+ .setData(android.net.Uri.parse("ninelives-widget://${launch.actionName()}/${launch.bookIdOrNull.orEmpty()}"))
+
/** The Binder's fill in template. Each cover adds its own extras. */
internal fun widgetActionIntent(context: Context): Intent =
Intent(context, WidgetActionActivity::class.java)
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt
index d6217cdf..fa4c9542 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt
@@ -41,10 +41,10 @@ internal class WidgetTap(val plan: Deferred, val act: Deferred WidgetPlayPlan.LOAD_BOOK
}
-/** A play or pause button on a widget, acting like the mini player's. */
+/**
+ * A play or pause button on a widget, acting like the mini player's. A locked
+ * tap goes to Unlock, and a shown book that cannot play right now opens its
+ * page, the same as a Binder cover (plan Design delta 5).
+ */
internal fun togglePlan(
isUnlocked: Boolean,
currentBookId: String?,
@@ -76,13 +80,33 @@ internal fun togglePlan(
bookLoading: Boolean,
shownBookId: String?,
shownPlayable: Boolean,
+ shownInActiveLibrary: Boolean = true,
+): WidgetPlayPlan = togglePlanLazy(
+ isUnlocked, currentBookId, state, bookLoading, shownBookId, shownInActiveLibrary,
+) { shownPlayable }
+
+/**
+ * Same decision as [togglePlan], but playability is only asked for when the
+ * tap would load a book. Pausing never waits on a server probe.
+ */
+internal inline fun togglePlanLazy(
+ isUnlocked: Boolean,
+ currentBookId: String?,
+ state: PlaybackState,
+ bookLoading: Boolean,
+ shownBookId: String?,
+ shownInActiveLibrary: Boolean = true,
+ shownPlayable: () -> Boolean,
): WidgetPlayPlan = when {
- !isUnlocked -> WidgetPlayPlan.IGNORE
+ !isUnlocked -> WidgetPlayPlan.OPEN_UNLOCK
+ // A stale button (widget not yet redrawn after a library or mode switch)
+ // never touches the new library's playback.
+ !shownInActiveLibrary -> WidgetPlayPlan.IGNORE
isLoading(state, bookLoading) -> WidgetPlayPlan.IGNORE
isAudible(state) -> WidgetPlayPlan.PAUSE
currentBookId != null -> WidgetPlayPlan.RESUME_CURRENT
shownBookId == null -> WidgetPlayPlan.IGNORE
- !shownPlayable -> WidgetPlayPlan.IGNORE
+ !shownPlayable() -> WidgetPlayPlan.OPEN_BOOK_PAGE
else -> WidgetPlayPlan.LOAD_BOOK
}
@@ -228,8 +252,10 @@ class WidgetPlayback @Inject constructor(
* Book Detail's Play gates: not archived, source reachable, remote media
* not blocked. A streamed book that reads blocked gets one server check
* first (see [settledConnectionStatus]), since a cold process has not run one.
+ * A redraw passes [settle] false: it reads the status as it is, and the
+ * check landing later redraws the widget anyway.
*/
- internal suspend fun isPlayable(book: AudioBook): Boolean {
+ internal suspend fun isPlayable(book: AudioBook, settle: Boolean = true): Boolean {
val library = book.libraryId?.let { libraryRepository.getById(it) }
val access = reconcileLocalBookAccess(
book,
@@ -242,6 +268,7 @@ class WidgetPlayback @Inject constructor(
connectionStatus = status,
)
if (decision(connectivityMonitor.connectionStatus.value) !is RemoteMediaAccessDecision.Blocked) return true
+ if (!settle) return false
val status = settledConnectionStatus(current = { connectivityMonitor.connectionStatus.value }) {
connectivityMonitor.refreshIsOnlineFromSystem()
withTimeoutOrNull(WIDGET_SERVER_CHECK_WAIT) { connectivityMonitor.checkServerReachable() }
@@ -289,8 +316,17 @@ class WidgetPlayback @Inject constructor(
}
}
- internal suspend fun toggle(shownBookId: String?): WidgetPlayPlan =
- scope.async { toggleOwned(shownBookId) }.await()
+ /**
+ * The large widget's play or pause button. Decided on this class's scope
+ * like a Binder tap, so the handoff screen can hold for the foreground
+ * start (WidgetActionActivity) without owning the load.
+ */
+ internal fun toggleTap(launch: WidgetLaunch.TogglePlay): WidgetTap {
+ val act = CompletableDeferred()
+ val plan = scope.async { toggleOwned(launch.bookId, act) }
+ plan.invokeOnCompletion { if (it != null) act.complete(Unit) }
+ return WidgetTap(plan, act)
+ }
/** The tapped book, only if it belongs to the library the app shows now. */
private suspend fun activeBook(bookId: String): AudioBook? =
@@ -325,26 +361,25 @@ class WidgetPlayback @Inject constructor(
binderOpenPlan(isUnlocked = true, inActiveLibrary = activeBook(bookId) != null)
}
- private suspend fun toggleOwned(shownBookId: String?): WidgetPlayPlan {
- playbackManager.awaitStartupRestore()
- val unlocked = effectiveSettings.isUnlockedNow
- val shown = if (unlocked && shownBookId != null) {
- withContext(Dispatchers.IO) { audioBookRepository.getById(shownBookId) }
- ?.takeIf { it.isInActiveLibrary(settingsManager.currentSettings) }
- } else {
- null
- }
- val plan = togglePlan(
- isUnlocked = unlocked,
+ private suspend fun toggleOwned(shownBookId: String, act: CompletableDeferred): WidgetPlayPlan = decideBinderTap(
+ isUnlocked = { effectiveSettings.isUnlockedNow },
+ awaitReady = { playbackManager.awaitStartupRestore() },
+ ) {
+ // A tap from a widget not yet redrawn after a mode or account switch
+ // carries a book from the old library. It never plays or opens.
+ val shown = activeBook(shownBookId)
+ val plan = togglePlanLazy(
+ isUnlocked = true,
currentBookId = playbackManager.currentBook.value?.id,
state = playbackManager.playbackState.value,
bookLoading = playbackManager.loadingBook.value != null,
shownBookId = shown?.id,
- shownPlayable = shown != null && isPlayable(shown),
+ shownInActiveLibrary = shown != null,
+ shownPlayable = { shown != null && isPlayable(shown) },
)
- launchAct(plan, shown)
- return plan
- }
+ launchAct(plan, shown, act)
+ plan
+ }.also { if (it == WidgetPlayPlan.OPEN_UNLOCK) act.complete(Unit) }
private fun launchAct(plan: WidgetPlayPlan, book: AudioBook?, done: CompletableDeferred? = null) {
Log.d(TAG, "plan $plan for ${book?.id}")
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt
index 9383acc9..d4428027 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggers.kt
@@ -27,14 +27,19 @@ internal data class WidgetTrigger(
val tick: Long,
/** Covers picked or undone in the app (CoverPicker). */
val coverCount: Long = 0,
+ /** A book is loading in the player. Its end redraws, so a failed load never leaves the dimmed button. */
+ val isLoading: Boolean = false,
)
+/** The player's part of a [WidgetTrigger]. */
+internal data class PlaybackTrigger(val bookId: String?, val isPlaying: Boolean, val chapterIndex: Int, val isLoading: Boolean)
+
enum class WidgetTarget { BINDER, HOME }
/**
* Which widgets need a redraw. Unlock, identity, sync, scan, a picked or
- * undone cover and the current book change what both show. Play state, chapter, reachability and the
- * playing tick only change the large widget's current book card.
+ * undone cover and the current book change what both show. Play state, a load starting or
+ * ending, chapter, reachability and the playing tick only change the large widget's current book card.
*/
internal fun widgetRefreshTargets(prev: WidgetTrigger?, next: WidgetTrigger): Set {
if (prev == null) return setOf(WidgetTarget.BINDER, WidgetTarget.HOME)
@@ -46,6 +51,7 @@ internal fun widgetRefreshTargets(prev: WidgetTrigger?, next: WidgetTrigger): Se
prev.currentBookId != next.currentBookId
) return setOf(WidgetTarget.BINDER, WidgetTarget.HOME)
if (prev.isPlaying != next.isPlaying ||
+ prev.isLoading != next.isLoading ||
prev.chapterIndex != next.chapterIndex ||
prev.serverReachable != next.serverReachable ||
prev.tick != next.tick
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt
index 63886af9..17f7ff64 100644
--- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt
@@ -15,6 +15,10 @@ import com.ninelivesaudio.app.service.cover.CoverPicker
import com.ninelivesaudio.app.service.local.LocalScanCoordinator
import com.ninelivesaudio.app.service.local.LocalScanState
import com.ninelivesaudio.app.widget.binder.BinderWidgetUpdater
+import com.ninelivesaudio.app.widget.home.HomeWidgetStore
+import com.ninelivesaudio.app.widget.home.NineLivesHomeWidget
+import androidx.glance.appwidget.GlanceAppWidgetManager
+import androidx.glance.appwidget.updateAll
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -54,6 +58,7 @@ class WidgetRefresher @Inject constructor(
private val connectivityMonitor: ConnectivityMonitor,
private val binderUpdater: BinderWidgetUpdater,
private val coverPicker: CoverPicker,
+ private val homeStore: HomeWidgetStore,
) {
private var started = false
private val pushLock = Mutex()
@@ -100,7 +105,8 @@ class WidgetRefresher @Inject constructor(
playbackManager.currentBook.map { it?.id },
playbackManager.playbackState.map { isPlayingForWidget(it) },
playbackManager.currentChapterIndex,
- ) { id, playing, chapter -> Triple(id, playing, chapter) }
+ playbackManager.loadingBook.map { it != null },
+ ) { id, playing, chapter, loading -> PlaybackTrigger(id, playing, chapter, loading) }
val counters = combine(syncCount, scanCount, tick, coverPicker.changeCount) { a, b, c, d -> listOf(a, b, c, d) }
val reachable = connectivityMonitor.connectionStatus.map { it == ConnectivityMonitor.ConnectionStatus.CONNECTED }
@@ -110,9 +116,10 @@ class WidgetRefresher @Inject constructor(
WidgetTrigger(
isUnlocked = unlocked,
identity = id,
- currentBookId = play.first,
- isPlaying = play.second,
- chapterIndex = play.third,
+ currentBookId = play.bookId,
+ isPlaying = play.isPlaying,
+ chapterIndex = play.chapterIndex,
+ isLoading = play.isLoading,
serverReachable = reach,
syncCount = count[0],
scanCount = count[1],
@@ -138,7 +145,7 @@ class WidgetRefresher @Inject constructor(
}
}
- /** Redraws [targets] now. HOME is a no op until the large widget exists (W2). */
+ /** Redraws [targets] now. A widget kind with none on the home screen reads nothing. */
suspend fun pushNow(targets: Set) = withContext(Dispatchers.IO) {
pushLock.withLock {
Log.d(TAG, "push $targets")
@@ -146,6 +153,15 @@ class WidgetRefresher @Inject constructor(
runCatching { binderUpdater.updateAll() }
.onFailure { Log.w(TAG, "binder update failed: ${it.message}") }
}
+ if (WidgetTarget.HOME in targets) {
+ runCatching {
+ val placed = GlanceAppWidgetManager(context).getGlanceIds(NineLivesHomeWidget::class.java)
+ if (placed.isNotEmpty()) {
+ homeStore.refresh()
+ NineLivesHomeWidget().updateAll(context)
+ }
+ }.onFailure { Log.w(TAG, "home update failed: ${it.message}") }
+ }
}
}
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt
new file mode 100644
index 00000000..bc2726aa
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt
@@ -0,0 +1,67 @@
+package com.ninelivesaudio.app.widget.home
+
+import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX
+import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX
+import com.ninelivesaudio.app.widget.rgb565Bytes
+
+/**
+ * What the Nine Lives widget keeps at a size. The design's priority order is
+ * rank card, then current book, then the next Lives, and smaller sizes drop
+ * from the bottom of that list: Lives first, then the rank card. The current
+ * book with its play button is in every layout.
+ */
+internal enum class HomeLayout { CURRENT_ONLY, RANK_AND_CURRENT, FULL }
+
+/** Width and height in dp, as Glance hands them to the layout. */
+internal fun homeLayoutFor(widthDp: Float, heightDp: Float): HomeLayout = when {
+ widthDp < 250f || heightDp < 180f -> HomeLayout.CURRENT_ONLY
+ heightDp < 260f -> HomeLayout.RANK_AND_CURRENT
+ else -> HomeLayout.FULL
+}
+
+/** How many next Lives fit (decision J3): three under 300dp wide, four from 300dp. */
+internal fun livesSlotsFor(widthDp: Float): Int = if (widthDp < 300f) 3 else 4
+
+/** The widest a Lives tile grows, so the full face still fits its height. */
+internal const val MAX_LIFE_TILE_DP = 72f
+
+/** Gap between two Lives tiles. */
+internal const val LIFE_GAP_DP = 10f
+
+/**
+ * One square Lives tile, in dp: [slots] tiles and their gaps share the width
+ * inside the widget's 12dp padding, capped at [MAX_LIFE_TILE_DP].
+ */
+internal fun lifeTileDp(widthDp: Float, slots: Int): Float =
+ minOf(MAX_LIFE_TILE_DP, (widthDp - 24f - LIFE_GAP_DP * (slots - 1)) / slots)
+
+/** Most Lives any layout shows. */
+internal const val MAX_LIVES = 4
+
+/**
+ * The Glance `SizeMode.Responsive` buckets, width to height in dp. Glance
+ * lays out each bucket once and the launcher picks the biggest that fits, so
+ * the layout only ever sees these sizes. One starts at 300dp wide so a widget
+ * that wide gets its four Lives (J3). The smallest matches the resize floor in
+ * widget_home_info.xml, since a launcher rounds cells and a 2x2 or a one row
+ * widget can come out under 180 by 110 (Pixel: about 172 by 99).
+ */
+internal val HOME_WIDGET_SIZES: List> =
+ listOf(160f to 80f, 160f to 180f, 250f to 180f, 250f to 260f, 300f to 260f)
+
+/** A current only widget tall enough to stack the cover and play above the title (a 2x2). */
+internal fun isTallCurrentOnly(widthDp: Float, heightDp: Float): Boolean =
+ homeLayoutFor(widthDp, heightDp) == HomeLayout.CURRENT_ONLY && heightDp >= 180f
+
+/** Cover bytes one bucket draws: the current book, plus the Lives only where they show. */
+internal fun homeBucketBitmapBytes(widthDp: Float, heightDp: Float): Int {
+ val lives = if (homeLayoutFor(widthDp, heightDp) == HomeLayout.FULL) livesSlotsFor(widthDp) else 0
+ return rgb565Bytes(HOME_CURRENT_COVER_PX) + lives * rgb565Bytes(HOME_LIFE_COVER_PX)
+}
+
+/**
+ * The most cover bytes one update can carry. API 30 sends one RemoteViews per
+ * size and does not share a bitmap between them, so every bucket counts.
+ */
+internal fun homeWorstCaseBitmapBytes(): Int =
+ HOME_WIDGET_SIZES.sumOf { (w, h) -> homeBucketBitmapBytes(w, h) }
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt
new file mode 100644
index 00000000..0267d13f
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt
@@ -0,0 +1,130 @@
+package com.ninelivesaudio.app.widget.home
+
+import com.ninelivesaudio.app.domain.model.AudioBook
+import com.ninelivesaudio.app.domain.model.Chapter
+import com.ninelivesaudio.app.service.PlaybackState
+import com.ninelivesaudio.app.ui.home.AltarCopy
+import com.ninelivesaudio.app.ui.home.RECENT_LIVES_LIMIT
+import com.ninelivesaudio.app.ui.home.formatListeningTime
+import com.ninelivesaudio.app.ui.home.totalTimeGivenSeconds
+import com.ninelivesaudio.app.widget.WidgetAccess
+import com.ninelivesaudio.app.widget.WidgetBookSource
+import com.ninelivesaudio.app.widget.WidgetContent
+import com.ninelivesaudio.app.widget.widgetContent
+import kotlin.time.DurationUnit
+
+/** The current book's button. WAIT is a dimmed play while a book loads, like the app. */
+internal enum class CurrentButton { PLAY, PAUSE, WAIT, OPEN }
+
+/** Home's altar card, the same words Home shows. */
+internal data class RankCard(val epithet: String, val totalTimeText: String)
+
+internal data class CurrentCard(
+ val bookId: String,
+ val title: String,
+ val chapterTitle: String?,
+ val button: CurrentButton,
+)
+
+internal data class LifeTile(val bookId: String, val title: String)
+
+/**
+ * Everything the Nine Lives widget draws, apart from cover art. Anything but
+ * READY carries no rank, book or Lives, so a locked or empty widget cannot
+ * show a stale one.
+ */
+internal data class HomeWidgetModel(
+ val content: WidgetContent,
+ val isLocalMode: Boolean,
+ val rank: RankCard?,
+ val current: CurrentCard?,
+ val lives: List,
+)
+
+/** The library reads one widget update makes. */
+internal data class HomeWidgetInputs(
+ val folderAccessible: Boolean,
+ val bookCount: Int,
+ val recent: List,
+)
+
+/** The chapter playing at [positionSeconds]. Past the end is the last chapter. */
+internal fun chapterTitleAt(chapters: List, positionSeconds: Double): String? {
+ if (chapters.isEmpty()) return null
+ val sorted = chapters.sortedBy { it.start }
+ return (sorted.lastOrNull { it.start <= positionSeconds } ?: sorted.first()).title
+}
+
+/**
+ * Builds the widget. The shown book is the one in the player, else the most
+ * recently played. The rank card is Home's: the same nine recent books, the
+ * same sum, the same words (plan Design delta 7).
+ */
+internal fun buildHomeWidgetModel(
+ access: WidgetAccess,
+ folderAccessible: Boolean,
+ bookCount: Int,
+ recent: List,
+ current: AudioBook?,
+ liveChapterTitle: String?,
+ state: PlaybackState,
+ bookLoading: Boolean,
+ shownPlayable: Boolean,
+): HomeWidgetModel {
+ val content = widgetContent(
+ access,
+ folderAccessible,
+ bookCount,
+ playedCount = recent.size + (if (current != null) 1 else 0),
+ needsPlayed = true,
+ )
+ val isLocalMode = when (access) {
+ is WidgetAccess.Library -> access.isLocalMode
+ is WidgetAccess.NoLibrary -> access.isLocalMode
+ else -> false
+ }
+ if (content != WidgetContent.READY) return HomeWidgetModel(content, isLocalMode, null, null, emptyList())
+
+ val total = totalTimeGivenSeconds(recent.map { it.currentTime.toDouble(DurationUnit.SECONDS) })
+ val rank = RankCard(AltarCopy.epithet(total), "Total time given: " + formatListeningTime(total))
+
+ val shown = current ?: recent.first()
+ val chapter = if (current != null) {
+ liveChapterTitle
+ } else {
+ chapterTitleAt(shown.chapters, shown.currentTime.toDouble(DurationUnit.SECONDS))
+ }?.takeIf { it.isNotBlank() }
+ val audible = state == PlaybackState.PLAYING || state == PlaybackState.BUFFERING
+ val button = when {
+ bookLoading || state == PlaybackState.LOADING -> CurrentButton.WAIT
+ current != null && audible -> CurrentButton.PAUSE
+ current != null -> CurrentButton.PLAY
+ !shownPlayable -> CurrentButton.OPEN
+ else -> CurrentButton.PLAY
+ }
+ val lives = recent.filter { it.id != shown.id }.take(MAX_LIVES).map { LifeTile(it.id, it.title) }
+ return HomeWidgetModel(
+ content = content,
+ isLocalMode = isLocalMode,
+ rank = rank,
+ current = CurrentCard(shown.id, shown.title, chapter, button),
+ lives = lives,
+ )
+}
+
+/**
+ * The reads for one update, for the library [access] chose. Locked, signed
+ * out and no library read nothing at all. A local library checks its folder
+ * grant before it reads any book.
+ */
+internal suspend fun loadHomeWidgetInputs(access: WidgetAccess, source: WidgetBookSource): HomeWidgetInputs {
+ val library = access as? WidgetAccess.Library ?: return HomeWidgetInputs(false, 0, emptyList())
+ if (library.isLocalMode && !source.folderAccessible(library.libraryId)) {
+ return HomeWidgetInputs(false, 0, emptyList())
+ }
+ return HomeWidgetInputs(
+ folderAccessible = true,
+ bookCount = source.countBooks(library.libraryId),
+ recent = source.recent(library.libraryId, RECENT_LIVES_LIMIT),
+ )
+}
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt
new file mode 100644
index 00000000..da65a2c1
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt
@@ -0,0 +1,188 @@
+package com.ninelivesaudio.app.widget.home
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.util.Log
+import androidx.glance.appwidget.updateAll
+import com.ninelivesaudio.app.data.repository.AudioBookRepository
+import com.ninelivesaudio.app.domain.model.AudioBook
+import com.ninelivesaudio.app.domain.model.isInActiveLibrary
+import com.ninelivesaudio.app.entitlement.EffectiveSettingsRepository
+import com.ninelivesaudio.app.service.PlaybackManager
+import com.ninelivesaudio.app.service.SettingsManager
+import com.ninelivesaudio.app.widget.HOME_CURRENT_COVER_PX
+import com.ninelivesaudio.app.widget.HOME_LIFE_COVER_PX
+import com.ninelivesaudio.app.widget.WidgetAccess
+import com.ninelivesaudio.app.widget.WidgetBookSource
+import com.ninelivesaudio.app.widget.WidgetContent
+import com.ninelivesaudio.app.widget.WidgetCoverLoader
+import com.ninelivesaudio.app.widget.WidgetPlayback
+import com.ninelivesaudio.app.widget.widgetAccess
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+
+/** Cover art for one draw. One bitmap per cover, shared by every size Glance lays out. */
+internal data class HomeWidgetArt(val current: Bitmap?, val lives: Map)
+
+internal data class HomeWidgetSnapshot(val model: HomeWidgetModel, val art: HomeWidgetArt)
+
+/**
+ * What the Nine Lives widget shows, kept in process for Glance to draw.
+ * [refresh] runs on the refresher's pushes and when a widget session starts
+ * cold. It never triggers a push itself, so it cannot loop.
+ */
+@Singleton
+class HomeWidgetStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settingsManager: SettingsManager,
+ private val effectiveSettings: EffectiveSettingsRepository,
+ private val source: WidgetBookSource,
+ private val playbackManager: PlaybackManager,
+ private val audioBookRepository: AudioBookRepository,
+ private val widgetPlayback: WidgetPlayback,
+ private val coverLoader: WidgetCoverLoader,
+) {
+ private val _state = MutableStateFlow(null)
+ internal val state: StateFlow = _state.asStateFlow()
+
+ private val lock = Mutex()
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var artJob: Job? = null
+ private var generation = 0L
+
+ /**
+ * Reads everything again. The access check comes first, and a locked,
+ * signed out or empty widget reads no books and no covers. Covers on disk
+ * draw at once. Missing ones download after, bounded and cancelled by the
+ * next refresh, and redraw the widget only if one arrived.
+ */
+ suspend fun refresh() = withContext(Dispatchers.IO) {
+ lock.withLock {
+ artJob?.cancel()
+ val mine = ++generation
+ withTimeoutOrNull(5.seconds) { settingsManager.isLoaded.first { it } }
+ val settings = settingsManager.currentSettings
+ val access = widgetAccess(
+ isUnlocked = effectiveSettings.isUnlockedNow,
+ settings = settings,
+ hasAuthToken = settingsManager.hasAuthToken.value,
+ )
+ val inputs = loadHomeWidgetInputs(access, source)
+ val library = access as? WidgetAccess.Library
+
+ // The player's book, else the one the app will restore, never another library's.
+ val playerBook = playbackManager.currentBook.value
+ ?.takeIf { library != null && it.libraryId == library.libraryId && it.isInActiveLibrary(settings) }
+ val current = playerBook ?: if (library != null && inputs.folderAccessible) {
+ settingsManager.getCurrentPlaybackBookId()
+ ?.let { audioBookRepository.getById(it) }
+ ?.takeIf { it.libraryId == library.libraryId && it.isInActiveLibrary(settings) && !it.isArchived }
+ } else {
+ null
+ }
+ val liveChapter = if (playerBook != null) {
+ playbackManager.currentChapter.value?.title
+ } else {
+ current?.let { chapterTitleAt(it.chapters, it.currentTime.toDouble(DurationUnit.SECONDS)) }
+ }
+ val shownPlayable = if (current == null && library != null) {
+ inputs.recent.firstOrNull()?.let { widgetPlayback.isPlayable(it, settle = false) } ?: false
+ } else {
+ true
+ }
+ val model = buildHomeWidgetModel(
+ access = access,
+ folderAccessible = inputs.folderAccessible,
+ bookCount = inputs.bookCount,
+ recent = inputs.recent,
+ current = current,
+ liveChapterTitle = liveChapter,
+ state = playbackManager.playbackState.value,
+ bookLoading = playbackManager.loadingBook.value != null,
+ shownPlayable = shownPlayable,
+ )
+ if (model.content != WidgetContent.READY) {
+ publish(HomeWidgetSnapshot(model, HomeWidgetArt(null, emptyMap())))
+ return@withLock
+ }
+
+ val byId = (inputs.recent + listOfNotNull(current)).associateBy { it.id }
+ val wanted = buildList {
+ model.current?.let { c -> byId[c.bookId]?.let { add(it to HOME_CURRENT_COVER_PX) } }
+ model.lives.forEach { l -> byId[l.bookId]?.let { add(it to HOME_LIFE_COVER_PX) } }
+ }
+ val cached = wanted.associate { (book, px) -> book.id to coverLoader.cached(book, px) }
+ publish(HomeWidgetSnapshot(model, art(model, cached)))
+
+ val missing = wanted.filter { (book, _) -> cached[book.id] == null && book.effectiveCoverPath != null }
+ if (missing.isNotEmpty()) {
+ artJob = scope.launch { fetchMissing(mine, model, cached, missing) }
+ }
+ }
+ }
+
+ private suspend fun fetchMissing(
+ mine: Long,
+ model: HomeWidgetModel,
+ cached: Map,
+ missing: List>,
+ ) {
+ val fetched = withTimeoutOrNull(COVER_FETCH_DEADLINE) {
+ missing.map { (book, px) ->
+ async {
+ try {
+ book.id to coverLoader.load(book, px)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "cover failed ${book.id}: ${e.message}")
+ book.id to null
+ }
+ }
+ }.awaitAll()
+ }.orEmpty().filter { it.second != null }
+ if (fetched.isEmpty()) return
+ lock.withLock {
+ if (generation != mine) return
+ publish(HomeWidgetSnapshot(model, art(model, cached + fetched)))
+ }
+ // A running Glance session redraws from the state flow. One that has
+ // closed is started again, and its cold start finds the covers on disk.
+ runCatching { NineLivesHomeWidget().updateAll(context) }
+ .onFailure { Log.w(TAG, "redraw after covers failed: ${it.message}") }
+ }
+
+ private fun art(model: HomeWidgetModel, covers: Map) = HomeWidgetArt(
+ current = model.current?.let { covers[it.bookId] },
+ lives = model.lives.mapNotNull { l -> covers[l.bookId]?.let { l.bookId to it } }.toMap(),
+ )
+
+ private fun publish(snapshot: HomeWidgetSnapshot) {
+ Log.d(TAG, "state ${snapshot.model.content} current=${snapshot.model.current?.button} lives=${snapshot.model.lives.size} art=${snapshot.art.current != null}/${snapshot.art.lives.size}")
+ _state.value = snapshot
+ }
+
+ private companion object {
+ const val TAG = "NineLivesHomeWidget"
+ val COVER_FETCH_DEADLINE = 8.seconds
+ }
+}
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt
new file mode 100644
index 00000000..72799865
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt
@@ -0,0 +1,358 @@
+package com.ninelivesaudio.app.widget.home
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.glance.GlanceId
+import androidx.glance.GlanceModifier
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalContext
+import androidx.glance.LocalSize
+import androidx.glance.action.Action
+import androidx.glance.action.clickable
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.SizeMode
+import androidx.glance.appwidget.action.actionStartActivity
+import androidx.glance.appwidget.appWidgetBackground
+import androidx.glance.appwidget.cornerRadius
+import androidx.glance.appwidget.provideContent
+import androidx.glance.background
+import androidx.glance.layout.Alignment
+import androidx.glance.layout.Box
+import androidx.glance.layout.Column
+import androidx.glance.layout.ContentScale
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.fillMaxWidth
+import androidx.glance.layout.height
+import androidx.glance.layout.padding
+import androidx.glance.layout.size
+import androidx.glance.layout.width
+import androidx.glance.semantics.contentDescription
+import androidx.glance.semantics.semantics
+import androidx.glance.text.FontWeight
+import androidx.glance.text.Text
+import androidx.glance.text.TextAlign
+import androidx.glance.text.TextStyle
+import androidx.glance.unit.ColorProvider
+import com.ninelivesaudio.app.R
+import com.ninelivesaudio.app.ui.theme.NoirColors
+import com.ninelivesaudio.app.widget.WidgetContent
+import com.ninelivesaudio.app.widget.WidgetCopy
+import com.ninelivesaudio.app.widget.WidgetLaunch
+import com.ninelivesaudio.app.widget.widgetActionIntent
+import com.ninelivesaudio.app.widget.widgetMainIntent
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+
+/**
+ * Nine Lives, the large home screen widget: Home's rank card, the current
+ * book with play and pause, and the next Lives. Always the Noir palette
+ * (plan Design delta 10). The model and covers come from [HomeWidgetStore],
+ * so drawing never reads the database and never touches the network.
+ *
+ * Every element that can be tapped in some state is given its action on
+ * every draw. A launcher can reapply a new draw onto the old views, and a
+ * view left without an action keeps the one it had.
+ */
+class NineLivesHomeWidget : GlanceAppWidget() {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface Deps {
+ fun homeWidgetStore(): HomeWidgetStore
+ }
+
+ override val sizeMode: SizeMode =
+ SizeMode.Responsive(HOME_WIDGET_SIZES.map { (w, h) -> DpSize(w.dp, h.dp) }.toSet())
+
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
+ val store = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java).homeWidgetStore()
+ if (store.state.value == null) store.refresh()
+ provideContent {
+ val snapshot by store.state.collectAsState()
+ HomeWidgetFace(snapshot)
+ }
+ }
+}
+
+private val textPrimary = ColorProvider(NoirColors.archiveTextPrimary)
+private val textSecondary = ColorProvider(NoirColors.archiveTextSecondary)
+private val textMuted = ColorProvider(NoirColors.archiveTextMuted)
+private val gold = ColorProvider(NoirColors.goldFilament)
+
+@Composable
+private fun HomeWidgetFace(snapshot: HomeWidgetSnapshot?) {
+ val context = LocalContext.current
+ val size = LocalSize.current
+ val layout = homeLayoutFor(size.width.value, size.height.value)
+ val model = snapshot?.model
+ val rootLaunch = if (model?.content == WidgetContent.LOCKED) WidgetLaunch.OpenUnlock else WidgetLaunch.OpenApp
+ val rootLabel = when (model?.content) {
+ WidgetContent.LOCKED -> WidgetCopy.CD_LOCKED
+ null, WidgetContent.READY -> WidgetCopy.CD_OPEN_APP
+ else -> WidgetCopy.emptyLine(model.content, model.isLocalMode) ?: WidgetCopy.CD_OPEN_APP
+ }
+ Box(
+ modifier = GlanceModifier
+ .fillMaxSize()
+ .appWidgetBackground()
+ .background(ImageProvider(R.drawable.widget_frame))
+ .cornerRadius(16.dp)
+ .clickable(actionStartActivity(widgetMainIntent(context, rootLaunch)))
+ .semantics { contentDescription = rootLabel }
+ .padding(if (layout == HomeLayout.CURRENT_ONLY) 10.dp else 12.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ when {
+ model == null -> Message(line = null, locked = false, layout = layout)
+ model.content == WidgetContent.LOCKED -> Message(WidgetCopy.HOME_LOCKED, locked = true, layout = layout)
+ model.content != WidgetContent.READY -> Message(WidgetCopy.emptyLine(model.content, model.isLocalMode), locked = false, layout = layout)
+ else -> Ready(model, snapshot.art, layout, size.width.value, isTallCurrentOnly(size.width.value, size.height.value))
+ }
+ }
+}
+
+/** Locked, signed out, empty: the eye and one calm line. Never a book, a cover or a rank. */
+@Composable
+private fun Message(line: String?, locked: Boolean, layout: HomeLayout) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(R.drawable.widget_eye),
+ contentDescription = null,
+ modifier = GlanceModifier.size(if (layout == HomeLayout.CURRENT_ONLY) 40.dp else 56.dp),
+ )
+ if (line != null) {
+ Spacer(GlanceModifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (locked) {
+ Image(
+ provider = ImageProvider(R.drawable.widget_lock),
+ contentDescription = null,
+ modifier = GlanceModifier.size(16.dp),
+ )
+ Spacer(GlanceModifier.width(6.dp))
+ }
+ Text(
+ text = line,
+ maxLines = 3,
+ style = TextStyle(color = textSecondary, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
+ )
+ }
+ }
+ if (locked && layout == HomeLayout.FULL) {
+ // An altar waiting for its Lives: empty sleeves, generic art only.
+ Spacer(GlanceModifier.height(14.dp))
+ Row {
+ repeat(3) { i ->
+ if (i > 0) Spacer(GlanceModifier.width(10.dp))
+ Image(
+ provider = ImageProvider(R.drawable.widget_sleeve_empty),
+ contentDescription = null,
+ modifier = GlanceModifier.size(40.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The rank card anchors to the top and the Lives to the bottom, with the
+ * current book between them, and equal weighted gaps take whatever height the
+ * launcher gives. Without Lives the current book sits in the middle of the
+ * space under the rank card. Only the one row face stays centered.
+ */
+@Composable
+private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float, tall: Boolean) {
+ val context = LocalContext.current
+ if (layout == HomeLayout.CURRENT_ONLY) {
+ Column(
+ modifier = GlanceModifier.fillMaxSize(),
+ verticalAlignment = if (tall) Alignment.Top else Alignment.CenterVertically,
+ ) {
+ model.current?.let {
+ if (tall) CurrentBookStacked(it, art.current) else CurrentBook(it, art.current, compact = true)
+ }
+ }
+ return
+ }
+ Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.Top) {
+ model.rank?.let { RankSlab(it, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) }
+ Spacer(GlanceModifier.defaultWeight())
+ model.current?.let { CurrentBook(it, art.current, compact = false) }
+ Spacer(GlanceModifier.defaultWeight())
+ val slots = livesSlotsFor(widthDp)
+ val lives = model.lives.take(slots)
+ if (layout == HomeLayout.FULL && lives.isNotEmpty()) {
+ Image(
+ provider = ImageProvider(R.drawable.widget_home_rule),
+ contentDescription = null,
+ contentScale = ContentScale.FillBounds,
+ modifier = GlanceModifier.fillMaxWidth().height(1.dp),
+ )
+ Spacer(GlanceModifier.height(LIFE_GAP_DP.dp))
+ // Every slot is laid out, the first and last flush with the edges and
+ // equal weighted gaps between, so the tiles span the full width at any
+ // real width. A slot without a Life stays empty, as on Home's grid.
+ val tile = lifeTileDp(widthDp, slots).toInt()
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ repeat(slots) { i ->
+ if (i > 0) Spacer(GlanceModifier.defaultWeight())
+ val life = lives.getOrNull(i)
+ if (life != null) {
+ Cover(
+ bitmap = art.lives[life.bookId],
+ label = life.title,
+ sizeDp = tile,
+ action = openBook(context, life.bookId),
+ )
+ } else {
+ Spacer(GlanceModifier.size(tile.dp))
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Home's altar card, small: the eye, the epithet, and the total time given. Opens Home. */
+@Composable
+private fun RankSlab(rank: RankCard, action: Action) {
+ Row(
+ modifier = GlanceModifier
+ .fillMaxWidth()
+ .background(ImageProvider(R.drawable.widget_home_slab))
+ .cornerRadius(12.dp)
+ .clickable(action)
+ .semantics { contentDescription = rank.epithet + ". " + rank.totalTimeText }
+ .padding(horizontal = 10.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Image(
+ provider = ImageProvider(R.drawable.widget_eye),
+ contentDescription = null,
+ modifier = GlanceModifier.size(40.dp),
+ )
+ Spacer(GlanceModifier.width(10.dp))
+ Column {
+ Text(
+ text = rank.epithet,
+ maxLines = 1,
+ style = TextStyle(color = textMuted, fontSize = 10.sp, fontWeight = FontWeight.Bold),
+ )
+ Spacer(GlanceModifier.height(2.dp))
+ Text(
+ text = rank.totalTimeText,
+ maxLines = 1,
+ style = TextStyle(color = gold, fontSize = 12.sp, fontWeight = FontWeight.Medium),
+ )
+ }
+ }
+}
+
+@Composable
+private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, compact: Boolean) {
+ val context = LocalContext.current
+ val open = openBook(context, current.bookId)
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Cover(bitmap = cover, label = current.title, sizeDp = if (compact) 48 else 64, action = open)
+ Spacer(GlanceModifier.width(if (compact) 8.dp else 10.dp))
+ Column(
+ modifier = GlanceModifier.defaultWeight().clickable(open),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = current.title,
+ maxLines = 2,
+ style = TextStyle(color = textPrimary, fontSize = if (compact) 13.sp else 14.sp, fontWeight = FontWeight.Medium),
+ )
+ current.chapterTitle?.let {
+ Spacer(GlanceModifier.height(2.dp))
+ Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp))
+ }
+ }
+ Spacer(GlanceModifier.width(if (compact) 6.dp else 8.dp))
+ PlayButton(current, open, if (compact) 40 else 44)
+ }
+}
+
+/** A 2x2: cover and play side by side at the top, the title and chapter anchored to the bottom. */
+@Composable
+private fun CurrentBookStacked(current: CurrentCard, cover: android.graphics.Bitmap?) {
+ val context = LocalContext.current
+ val open = openBook(context, current.bookId)
+ Column(modifier = GlanceModifier.fillMaxSize()) {
+ Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Cover(bitmap = cover, label = current.title, sizeDp = 72, action = open)
+ Spacer(GlanceModifier.defaultWeight())
+ PlayButton(current, open, 44)
+ }
+ Spacer(GlanceModifier.height(10.dp))
+ Spacer(GlanceModifier.defaultWeight())
+ Column(modifier = GlanceModifier.fillMaxWidth().clickable(open)) {
+ Text(
+ text = current.title,
+ maxLines = 2,
+ style = TextStyle(color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium),
+ )
+ current.chapterTitle?.let {
+ Spacer(GlanceModifier.height(2.dp))
+ Text(text = it, maxLines = 1, style = TextStyle(color = textSecondary, fontSize = 12.sp))
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlayButton(current: CurrentCard, open: Action, sizeDp: Int) {
+ val context = LocalContext.current
+ val (icon, label, action) = when (current.button) {
+ CurrentButton.PLAY -> Triple(R.drawable.widget_home_play, WidgetCopy.CD_PLAY, toggle(context, current.bookId))
+ CurrentButton.PAUSE -> Triple(R.drawable.widget_home_pause, WidgetCopy.CD_PAUSE, toggle(context, current.bookId))
+ // Loading: the app ignores a tap now, and so does this one (the plan says IGNORE),
+ // but the button still carries an action so it never keeps an old one.
+ CurrentButton.WAIT -> Triple(R.drawable.widget_home_wait, WidgetCopy.CD_PLAY, toggle(context, current.bookId))
+ CurrentButton.OPEN -> Triple(R.drawable.widget_home_open, current.title, open)
+ }
+ Image(
+ provider = ImageProvider(icon),
+ contentDescription = label,
+ modifier = GlanceModifier.size(sizeDp.dp).clickable(action),
+ )
+}
+
+/**
+ * A square cover, fitted whole: the bitmap is already square with its
+ * corners rounded on the widget's own surface colour (WidgetCoverLoader), so
+ * nothing is cropped and the corners match on every API level.
+ */
+@Composable
+private fun Cover(bitmap: android.graphics.Bitmap?, label: String, sizeDp: Int, action: Action) {
+ Image(
+ provider = bitmap?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.widget_sleeve_empty),
+ contentDescription = label,
+ contentScale = ContentScale.Fit,
+ modifier = GlanceModifier.size(sizeDp.dp).clickable(action),
+ )
+}
+
+/** Open its page goes through the trampoline, which drops a stale tap from another library. */
+private fun openBook(context: Context, bookId: String): Action =
+ actionStartActivity(widgetActionIntent(context, WidgetLaunch.OpenBook(bookId)))
+
+/** Play or pause, through the trampoline that holds until the service is in the foreground. */
+private fun toggle(context: Context, bookId: String): Action =
+ actionStartActivity(widgetActionIntent(context, WidgetLaunch.TogglePlay(bookId)))
diff --git a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt
new file mode 100644
index 00000000..93895f3f
--- /dev/null
+++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt
@@ -0,0 +1,52 @@
+package com.ninelivesaudio.app.widget.home
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.util.Log
+import androidx.glance.appwidget.GlanceAppWidget
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import com.ninelivesaudio.app.entitlement.EntitlementRepository
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+/**
+ * The Nine Lives widget's provider. Redraws come from the app's own events
+ * through `WidgetRefresher`. The system's hourly update is a backstop: it
+ * re-resolves entitlement first, so a trial that ended with the app closed
+ * relocks here, the same as the Binder.
+ */
+class NineLivesHomeWidgetReceiver : GlanceAppWidgetReceiver() {
+
+ override val glanceAppWidget: GlanceAppWidget = NineLivesHomeWidget()
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface Deps {
+ fun homeWidgetStore(): HomeWidgetStore
+ fun entitlementRepository(): EntitlementRepository
+ }
+
+ override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ // Glance's own update holds the broadcast and runs the session, which
+ // draws from the store and redraws when the refresh below lands.
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ val deps = EntryPointAccessors.fromApplication(context.applicationContext, Deps::class.java)
+ backstopScope.launch {
+ runCatching {
+ deps.entitlementRepository().refresh()
+ deps.homeWidgetStore().refresh()
+ }.onFailure { Log.w(TAG, "backstop refresh failed: ${it.message}") }
+ }
+ }
+
+ private companion object {
+ const val TAG = "NineLivesHomeWidget"
+ val backstopScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ }
+}
diff --git a/app/src/main/res/drawable/widget_home_open.xml b/app/src/main/res/drawable/widget_home_open.xml
new file mode 100644
index 00000000..6f64ae3b
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_open.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_home_pause.xml b/app/src/main/res/drawable/widget_home_pause.xml
new file mode 100644
index 00000000..19c70a8e
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_pause.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_home_play.xml b/app/src/main/res/drawable/widget_home_play.xml
new file mode 100644
index 00000000..cc878f1a
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_play.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_home_rule.xml b/app/src/main/res/drawable/widget_home_rule.xml
new file mode 100644
index 00000000..b2e454a6
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_rule.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_home_slab.xml b/app/src/main/res/drawable/widget_home_slab.xml
new file mode 100644
index 00000000..48177e98
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_slab.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_home_wait.xml b/app/src/main/res/drawable/widget_home_wait.xml
new file mode 100644
index 00000000..00bbb910
--- /dev/null
+++ b/app/src/main/res/drawable/widget_home_wait.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_home_preview.xml b/app/src/main/res/layout/widget_home_preview.xml
new file mode 100644
index 00000000..33e28e8e
--- /dev/null
+++ b/app/src/main/res/layout/widget_home_preview.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index afcac2bf..1290165f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -12,4 +12,6 @@
Binder
Flip through your books like an old CD binder
+ Nine Lives
+ Your rank, your current book, and your next lives
diff --git a/app/src/main/res/xml/widget_home_info.xml b/app/src/main/res/xml/widget_home_info.xml
new file mode 100644
index 00000000..973179f0
--- /dev/null
+++ b/app/src/main/res/xml/widget_home_info.xml
@@ -0,0 +1,17 @@
+
+
+
diff --git a/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt b/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt
new file mode 100644
index 00000000..93da399c
--- /dev/null
+++ b/app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt
@@ -0,0 +1,19 @@
+package com.ninelivesaudio.app.ui.home
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ListeningTimeFormatTest {
+
+ @Test fun `listening time reads the way the altar card always has`() {
+ assertEquals("< 1m", formatListeningTime(30.0))
+ assertEquals("45m", formatListeningTime(45 * 60.0))
+ assertEquals("3h 22m", formatListeningTime(3 * 3600.0 + 22 * 60.0))
+ }
+
+ @Test fun `total time given is the sum over the recent lives`() {
+ assertEquals(5400.0, totalTimeGivenSeconds(listOf(1800.0, 3600.0)), 0.0)
+ assertEquals(0.0, totalTimeGivenSeconds(emptyList()), 0.0)
+ assertEquals(9, RECENT_LIVES_LIMIT)
+ }
+}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt b/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt
new file mode 100644
index 00000000..ebdfad84
--- /dev/null
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt
@@ -0,0 +1,25 @@
+package com.ninelivesaudio.app.widget
+
+import com.ninelivesaudio.app.domain.model.AudioBook
+
+/** A [WidgetBookSource] that records which reads ran, shared by the widget tests. */
+internal class FakeSource(
+ private val folderOk: Boolean = true,
+ private val books: List = emptyList(),
+) : WidgetBookSource {
+ val calls = mutableListOf()
+ var lastLibraryId: String? = null
+
+ override suspend fun folderAccessible(libraryId: String): Boolean {
+ calls += "folderAccessible"; lastLibraryId = libraryId; return folderOk
+ }
+ override suspend fun countBooks(libraryId: String): Int {
+ calls += "countBooks"; lastLibraryId = libraryId; return books.size
+ }
+ override suspend fun shelf(libraryId: String): List {
+ calls += "shelf"; lastLibraryId = libraryId; return books
+ }
+ override suspend fun recent(libraryId: String, limit: Int): List {
+ calls += "recent"; lastLibraryId = libraryId; return books.take(limit)
+ }
+}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt
index 3bb5a109..6b4c3515 100644
--- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetBookSourceTest.kt
@@ -10,27 +10,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
-internal class FakeSource(
- private val folderOk: Boolean = true,
- private val books: List = emptyList(),
-) : WidgetBookSource {
- val calls = mutableListOf()
- var lastLibraryId: String? = null
-
- override suspend fun folderAccessible(libraryId: String): Boolean {
- calls += "folderAccessible"; lastLibraryId = libraryId; return folderOk
- }
- override suspend fun countBooks(libraryId: String): Int {
- calls += "countBooks"; lastLibraryId = libraryId; return books.size
- }
- override suspend fun shelf(libraryId: String): List {
- calls += "shelf"; lastLibraryId = libraryId; return books
- }
- override suspend fun recent(libraryId: String, limit: Int): List {
- calls += "recent"; lastLibraryId = libraryId; return books.take(limit)
- }
-}
-
class WidgetBookSourceTest {
@Test fun `a locked binder never touches the library`() = runBlocking {
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt
index 0a02f307..6afaeed3 100644
--- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt
@@ -7,13 +7,14 @@ import org.junit.Test
class WidgetLaunchTest {
@Test fun `every launch survives the intent extras`() {
- for (l in listOf(WidgetLaunch.OpenApp, WidgetLaunch.OpenBook("b1"), WidgetLaunch.PlayBook("b1"), WidgetLaunch.OpenUnlock))
+ for (l in listOf(WidgetLaunch.OpenApp, WidgetLaunch.OpenBook("b1"), WidgetLaunch.PlayBook("b1"), WidgetLaunch.OpenUnlock, WidgetLaunch.TogglePlay("b1")))
assertEquals(l, parseWidgetLaunch(l.actionName(), l.bookIdOrNull))
}
@Test fun `a book launch without a book is no launch`() {
assertNull(parseWidgetLaunch("open_book", null))
assertNull(parseWidgetLaunch("play_book", " "))
+ assertNull(parseWidgetLaunch("toggle_play", null))
assertNull(parseWidgetLaunch("something_else", "b1"))
assertNull(parseWidgetLaunch(null, null))
}
@@ -26,5 +27,14 @@ class WidgetLaunchTest {
assertEquals(WidgetRoute.Book("b1"), widgetRoute(WidgetLaunch.OpenBook("b1")))
assertEquals(WidgetRoute.Unlock, widgetRoute(WidgetLaunch.OpenUnlock))
assertNull(widgetRoute(WidgetLaunch.PlayBook("b1")))
+ assertNull(widgetRoute(WidgetLaunch.TogglePlay("b1")))
+ }
+
+ @Test fun `a widget tap for another book opens that book even when a book page is on top`() {
+ // Single top matched the route pattern, so book B reused book A's page.
+ assertEquals(true, opensNewBookPage(topRoute = "book_detail/{bookId}", topBookId = "a", bookId = "b"))
+ assertEquals(false, opensNewBookPage(topRoute = "book_detail/{bookId}", topBookId = "b", bookId = "b"))
+ assertEquals(true, opensNewBookPage(topRoute = "home", topBookId = null, bookId = "b"))
+ assertEquals(true, opensNewBookPage(topRoute = null, topBookId = null, bookId = "b"))
}
}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt
index 67ae8925..35c758dc 100644
--- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt
@@ -38,14 +38,37 @@ class WidgetPlaybackTest {
assertEquals(WidgetPlayPlan.LOAD_BOOK, binderPlayPlan(true, "b2", "b1", PlaybackState.PAUSED, false, playable = true))
}
+ @Test fun `a locked play button sends the tap to the unlock screen`() {
+ assertEquals(WidgetPlayPlan.OPEN_UNLOCK, togglePlan(false, "b1", PlaybackState.PLAYING, false, "b1", true))
+ }
+
@Test fun `play pause toggles the retained book like the mini player`() {
assertEquals(WidgetPlayPlan.PAUSE, togglePlan(true, "b1", PlaybackState.BUFFERING, false, "b1", true))
assertEquals(WidgetPlayPlan.RESUME_CURRENT, togglePlan(true, "b1", PlaybackState.STOPPED, false, "b1", true))
}
+ @Test fun `a stale shown book from another library is ignored even while playing`() {
+ assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, "new", PlaybackState.PLAYING, false, shownBookId = "old", shownPlayable = true, shownInActiveLibrary = false))
+ assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, "new", PlaybackState.PAUSED, false, shownBookId = "old", shownPlayable = true, shownInActiveLibrary = false))
+ assertEquals(WidgetPlayPlan.PAUSE, togglePlan(true, "b1", PlaybackState.PLAYING, false, shownBookId = "b1", shownPlayable = true, shownInActiveLibrary = true))
+ }
+
+ @Test fun `a pause tap never runs the playability probe`() = runBlocking {
+ var probed = 0
+ val probe: suspend () -> Boolean = { probed++; awaitCancellation() }
+ assertEquals(WidgetPlayPlan.PAUSE, togglePlanLazy(true, "b1", PlaybackState.PLAYING, false, "b1", true) { probe() })
+ assertEquals(WidgetPlayPlan.PAUSE, togglePlanLazy(true, "b1", PlaybackState.BUFFERING, false, "b1", true) { probe() })
+ assertEquals(WidgetPlayPlan.IGNORE, togglePlanLazy(true, "new", PlaybackState.PLAYING, false, "old", false) { probe() })
+ assertEquals(0, probed)
+ var loadProbed = 0
+ assertEquals(WidgetPlayPlan.LOAD_BOOK, togglePlanLazy(true, null, PlaybackState.STOPPED, false, "b3", true) { loadProbed++; true })
+ assertEquals(1, loadProbed)
+ }
+
@Test fun `with nothing retained play loads the book the widget shows`() {
assertEquals(WidgetPlayPlan.LOAD_BOOK, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = true))
- assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = false))
+ // Like a Binder cover: a book that cannot play right now opens its page, which says why.
+ assertEquals(WidgetPlayPlan.OPEN_BOOK_PAGE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = false))
assertEquals(WidgetPlayPlan.IGNORE, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = null, shownPlayable = true))
}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt
index 340e0182..12938922 100644
--- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetRefreshTriggersTest.kt
@@ -41,6 +41,14 @@ class WidgetRefreshTriggersTest {
assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(t0, next))
}
+ @Test fun `a load starting or ending redraws only the large widget`() {
+ // A failed load leaves the same book, not audible: without this the
+ // large widget kept the dimmed loading button until some other event.
+ val loading = t0.copy(isLoading = true)
+ assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(t0, loading))
+ assertEquals(setOf(WidgetTarget.HOME), widgetRefreshTargets(loading, t0))
+ }
+
@Test fun `nothing changed means no redraw`() {
assertEquals(emptySet(), widgetRefreshTargets(t0, t0.copy()))
}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt
new file mode 100644
index 00000000..fa2ac0be
--- /dev/null
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt
@@ -0,0 +1,68 @@
+package com.ninelivesaudio.app.widget.home
+
+import com.ninelivesaudio.app.widget.WIDGET_BITMAP_BUDGET_BYTES
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HomeWidgetLayoutTest {
+
+ @Test fun `smaller sizes drop the lives first, then the rank card`() {
+ assertEquals(HomeLayout.FULL, homeLayoutFor(320f, 270f))
+ assertEquals(HomeLayout.RANK_AND_CURRENT, homeLayoutFor(320f, 259f))
+ assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(320f, 179f))
+ assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(249f, 400f))
+ }
+
+ @Test fun `every responsive bucket maps to a layout that keeps play`() {
+ assertEquals(
+ listOf(HomeLayout.CURRENT_ONLY, HomeLayout.CURRENT_ONLY, HomeLayout.RANK_AND_CURRENT, HomeLayout.FULL, HomeLayout.FULL),
+ HOME_WIDGET_SIZES.map { (w, h) -> homeLayoutFor(w, h) },
+ )
+ }
+
+ @Test fun `the smallest bucket is the resize floor and keeps play`() {
+ assertEquals(160f to 80f, HOME_WIDGET_SIZES.first())
+ assertEquals(HomeLayout.CURRENT_ONLY, homeLayoutFor(160f, 80f))
+ }
+
+ @Test fun `wider widgets show four lives`() {
+ assertEquals(3, livesSlotsFor(299f))
+ assertEquals(4, livesSlotsFor(300f))
+ }
+
+ @Test fun `the four lives breakpoint is a responsive bucket, so a 300dp widget gets four`() {
+ // Glance hands the layout the bucket's size, not the real one, so the
+ // J3 breakpoint only holds if some bucket starts exactly there.
+ assertTrue(HOME_WIDGET_SIZES.any { (w, h) -> w == 300f && homeLayoutFor(w, h) == HomeLayout.FULL })
+ assertEquals(3, HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }.minOf { livesSlotsFor(it.first) })
+ }
+
+ @Test fun `the large widget stays inside the bitmap budget at every size`() {
+ assertTrue(homeWorstCaseBitmapBytes() <= WIDGET_BITMAP_BUDGET_BYTES)
+ // Lives only count where they are drawn.
+ assertEquals(192 * 192 * 2, homeBucketBitmapBytes(250f, 180f))
+ assertEquals(192 * 192 * 2 + 4 * 128 * 128 * 2, homeBucketBitmapBytes(300f, 260f))
+ }
+
+ @Test fun `a 2x2 stacks the current book, a short one keeps it in a row`() {
+ assertTrue(isTallCurrentOnly(160f, 180f))
+ assertTrue(!isTallCurrentOnly(160f, 80f))
+ assertTrue(!isTallCurrentOnly(250f, 180f))
+ }
+
+ @Test fun `the lives share the row width evenly, square, with 10dp gaps`() {
+ // 12dp padding a side, so the tiles and their gaps span the width inside it.
+ assertEquals((300f - 24f - 3 * 10f) / 4, lifeTileDp(300f, 4), 0.01f)
+ assertEquals((250f - 24f - 2 * 10f) / 3, lifeTileDp(250f, 3), 0.01f)
+ // A very wide widget stops growing the tiles, so the column still fits its height.
+ assertEquals(MAX_LIFE_TILE_DP, lifeTileDp(600f, 3), 0.01f)
+ }
+
+ @Test fun `the full face fits its smallest height with the tiles at their size`() {
+ // Rank slab 56, current book 64, rule 1 plus its 10dp gap, the tiles, 12dp padding a side.
+ for ((w, h) in HOME_WIDGET_SIZES.filter { homeLayoutFor(it.first, it.second) == HomeLayout.FULL }) {
+ assertTrue("$w x $h", 56f + 64f + 11f + lifeTileDp(w, livesSlotsFor(w)) + 24f <= h)
+ }
+ }
+}
diff --git a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt
new file mode 100644
index 00000000..12eaa58b
--- /dev/null
+++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt
@@ -0,0 +1,140 @@
+package com.ninelivesaudio.app.widget.home
+
+import com.ninelivesaudio.app.domain.model.AudioBook
+import com.ninelivesaudio.app.domain.model.Chapter
+import com.ninelivesaudio.app.service.PlaybackState
+import com.ninelivesaudio.app.ui.home.AltarCopy
+import com.ninelivesaudio.app.ui.home.formatListeningTime
+import com.ninelivesaudio.app.widget.FakeSource
+import com.ninelivesaudio.app.widget.WidgetAccess
+import com.ninelivesaudio.app.widget.WidgetContent
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
+
+class HomeWidgetModelTest {
+
+ private val books = (1..5).map { i -> AudioBook(id = "b$i", title = "Book $i", currentTime = (i * 600).seconds) }
+ private val ch = listOf(Chapter(0, 0.0, 300.0, "One"), Chapter(1, 300.0, 900.0, "Two"))
+ private val lib = WidgetAccess.Library("lib_s", false)
+
+ private fun ready(
+ current: AudioBook?,
+ liveChapter: String? = null,
+ state: PlaybackState = PlaybackState.STOPPED,
+ loading: Boolean = false,
+ playable: Boolean = true,
+ ) = buildHomeWidgetModel(lib, true, 5, books, current, liveChapter, state, loading, playable)
+
+ @Test fun `a locked large widget reads nothing and shows nothing personal`() = runBlocking {
+ val src = FakeSource(books = books)
+ assertEquals(HomeWidgetInputs(false, 0, emptyList()), loadHomeWidgetInputs(WidgetAccess.Locked, src))
+ assertEquals(emptyList(), src.calls)
+ val m = buildHomeWidgetModel(WidgetAccess.Locked, false, 0, emptyList(), null, null, PlaybackState.STOPPED, false, false)
+ assertEquals(WidgetContent.LOCKED, m.content)
+ assertNull(m.rank)
+ assertNull(m.current)
+ assertTrue(m.lives.isEmpty())
+ }
+
+ @Test fun `a locked widget shows nothing even when handed books`() {
+ val m = buildHomeWidgetModel(WidgetAccess.Locked, true, 5, books, books[0], "Live", PlaybackState.PLAYING, false, true)
+ assertEquals(WidgetContent.LOCKED, m.content)
+ assertNull(m.rank)
+ assertNull(m.current)
+ assertTrue(m.lives.isEmpty())
+ }
+
+ @Test fun `signed out and no library read nothing`() = runBlocking {
+ val src = FakeSource(books = books)
+ loadHomeWidgetInputs(WidgetAccess.SignedOut, src)
+ loadHomeWidgetInputs(WidgetAccess.NoLibrary(isLocalMode = true), src)
+ assertEquals(emptyList(), src.calls)
+ }
+
+ @Test fun `a local library with its folder gone stops before reading books`() = runBlocking {
+ val src = FakeSource(folderOk = false, books = books)
+ val inputs = loadHomeWidgetInputs(WidgetAccess.Library("lib_l", true), src)
+ assertEquals(listOf("folderAccessible"), src.calls)
+ val m = buildHomeWidgetModel(WidgetAccess.Library("lib_l", true), inputs.folderAccessible, inputs.bookCount, inputs.recent, null, null, PlaybackState.STOPPED, false, true)
+ assertEquals(WidgetContent.FOLDER_LOST, m.content)
+ assertNull(m.current)
+ }
+
+ @Test fun `a server library reads its count and the same nine recent books as Home`() = runBlocking {
+ val many = (1..12).map { AudioBook(id = "m$it") }
+ val src = FakeSource(books = many)
+ val inputs = loadHomeWidgetInputs(lib, src)
+ assertEquals(listOf("countBooks", "recent"), src.calls)
+ assertEquals("lib_s", src.lastLibraryId)
+ assertEquals(9, inputs.recent.size)
+ assertEquals(12, inputs.bookCount)
+ assertTrue(inputs.folderAccessible)
+ }
+
+ @Test fun `rank card matches Home's altar for the same books`() {
+ val m = ready(current = null)
+ val total = books.sumOf { it.currentTime.inWholeSeconds.toDouble() }
+ assertEquals(AltarCopy.epithet(total), m.rank!!.epithet)
+ assertEquals("Total time given: " + formatListeningTime(total), m.rank!!.totalTimeText)
+ }
+
+ @Test fun `the current book is the one in the player, else the most recent`() {
+ assertEquals("b3", ready(current = books[2]).current!!.bookId)
+ assertEquals("b1", ready(current = null).current!!.bookId)
+ }
+
+ @Test fun `the next lives skip the current book and stop at four`() {
+ assertEquals(listOf("b1", "b2", "b4", "b5"), ready(current = books[2]).lives.map { it.bookId })
+ assertEquals(listOf("b2", "b3", "b4", "b5"), ready(current = null).lives.map { it.bookId })
+ }
+
+ @Test fun `chapter comes from the player when live, else from the saved position`() {
+ assertEquals("Live", ready(current = books[0], liveChapter = "Live").current!!.chapterTitle)
+ val cold = buildHomeWidgetModel(lib, true, 5, listOf(books[0].copy(chapters = ch, currentTime = 400.seconds)), null, null, PlaybackState.STOPPED, false, true)
+ assertEquals("Two", cold.current!!.chapterTitle)
+ assertNull(ready(current = books[0], liveChapter = " ").current!!.chapterTitle)
+ }
+
+ @Test fun `chapter lookup handles edges`() {
+ assertNull(chapterTitleAt(emptyList(), 10.0))
+ assertEquals("One", chapterTitleAt(ch, 0.0))
+ assertEquals("Two", chapterTitleAt(ch, 300.0))
+ assertEquals("Two", chapterTitleAt(ch, 5_000.0))
+ assertEquals("One", chapterTitleAt(ch, -5.0))
+ }
+
+ @Test fun `the button follows the player and never offers play during a load`() {
+ assertEquals(CurrentButton.WAIT, ready(current = books[0], state = PlaybackState.PAUSED, loading = true).current!!.button)
+ assertEquals(CurrentButton.WAIT, ready(current = books[0], state = PlaybackState.LOADING).current!!.button)
+ assertEquals(CurrentButton.PAUSE, ready(current = books[0], state = PlaybackState.PLAYING).current!!.button)
+ assertEquals(CurrentButton.PAUSE, ready(current = books[0], state = PlaybackState.BUFFERING).current!!.button)
+ assertEquals(CurrentButton.PLAY, ready(current = books[0], state = PlaybackState.STOPPED).current!!.button)
+ assertEquals(CurrentButton.PLAY, ready(current = books[0], state = PlaybackState.PAUSED, playable = false).current!!.button)
+ assertEquals(CurrentButton.OPEN, ready(current = null, playable = false).current!!.button)
+ assertEquals(CurrentButton.PLAY, ready(current = null, playable = true).current!!.button)
+ }
+
+ @Test fun `a book in the player with no saved progress yet still shows`() {
+ val m = buildHomeWidgetModel(lib, true, 5, emptyList(), books[0], null, PlaybackState.PAUSED, false, true)
+ assertEquals(WidgetContent.READY, m.content)
+ assertEquals("b1", m.current!!.bookId)
+ assertTrue(m.lives.isEmpty())
+ }
+
+ @Test fun `books but nothing played yet is its own calm state`() {
+ val m = buildHomeWidgetModel(lib, true, 5, emptyList(), null, null, PlaybackState.STOPPED, false, true)
+ assertEquals(WidgetContent.NOTHING_PLAYED, m.content)
+ assertNull(m.current)
+ assertNull(m.rank)
+ }
+
+ @Test fun `an empty library shows no books, never an old rank`() {
+ val m = buildHomeWidgetModel(lib, true, 0, emptyList(), null, null, PlaybackState.STOPPED, false, true)
+ assertEquals(WidgetContent.NO_BOOKS, m.content)
+ assertNull(m.rank)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index bf9d9f5e..87717b77 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ espressoCore = "3.6.1"
ksp = "2.2.10-2.0.2"
coroutines = "1.10.2"
work = "2.9.1"
+glance = "1.1.1"
# Play Billing. Floor is 8.0 for any update submitted after 2026-08-31, so
# integrate on current and skip the migration entirely.
billing = "9.1.0"
@@ -96,6 +97,7 @@ kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-co
# WorkManager (background downloads)
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" }
+androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" }
# Play Billing (one-time unlock)
billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }