From 50957fc9a093e50f706d9d0f839c526494d39a10 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:14:16 -0400 Subject: [PATCH 01/11] Home's rank card numbers come from one shared source, and Glance joins the build Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- app/build.gradle.kts | 1 + .../app/ui/home/HomeViewModel.kt | 17 +++--------- .../app/ui/home/ListeningTimeFormat.kt | 26 +++++++++++++++++++ .../app/ui/home/ListeningTimeFormatTest.kt | 19 ++++++++++++++ gradle/libs.versions.toml | 2 ++ 5 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormat.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/ui/home/ListeningTimeFormatTest.kt 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/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/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/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" } From a89509a67f3bdaf1c87f932c20bac094bbcbcc08 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:16:24 -0400 Subject: [PATCH 02/11] The Nine Lives widget knows what to show at every size, matching Home's rank card Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../app/widget/home/HomeWidgetLayout.kt | 42 ++++++ .../app/widget/home/HomeWidgetModel.kt | 130 ++++++++++++++++ .../app/widget/FakeWidgetBookSource.kt | 25 ++++ .../app/widget/WidgetBookSourceTest.kt | 21 --- .../app/widget/home/HomeWidgetLayoutTest.kt | 39 +++++ .../app/widget/home/HomeWidgetModelTest.kt | 140 ++++++++++++++++++ 6 files changed, 376 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetModel.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/widget/FakeWidgetBookSource.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetModelTest.kt 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..955383ec --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -0,0 +1,42 @@ +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 + +/** 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). + */ +internal val HOME_WIDGET_SIZES: List> = + listOf(180f to 110f, 250f to 180f, 250f to 260f, 300f to 260f) + +/** + * 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.size * (rgb565Bytes(HOME_CURRENT_COVER_PX) + MAX_LIVES * rgb565Bytes(HOME_LIFE_COVER_PX)) 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/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/home/HomeWidgetLayoutTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt new file mode 100644 index 00000000..742a79b4 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -0,0 +1,39 @@ +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.RANK_AND_CURRENT, HomeLayout.FULL, HomeLayout.FULL), + HOME_WIDGET_SIZES.map { (w, h) -> homeLayoutFor(w, h) }, + ) + } + + @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) + } +} 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) + } +} From a390d5504c137995a321ae1f306140a1ca4ecaca Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:23:56 -0400 Subject: [PATCH 03/11] The Nine Lives widget: your rank, your book with play and pause, and your next lives Play and pause go through the same invisible handoff screen as the Binder, so a cold start holds until playback is in the foreground. A book that cannot play right now opens its page instead, like a Binder cover. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- app/src/main/AndroidManifest.xml | 13 + .../app/widget/WidgetActionActivity.kt | 1 + .../ninelivesaudio/app/widget/WidgetLaunch.kt | 17 + .../app/widget/WidgetPlayback.kt | 52 +-- .../app/widget/home/HomeWidgetStore.kt | 188 +++++++++++ .../app/widget/home/NineLivesHomeWidget.kt | 304 ++++++++++++++++++ .../home/NineLivesHomeWidgetReceiver.kt | 52 +++ .../main/res/drawable/widget_home_open.xml | 16 + .../main/res/drawable/widget_home_pause.xml | 14 + .../main/res/drawable/widget_home_play.xml | 14 + .../main/res/drawable/widget_home_rule.xml | 5 + .../main/res/drawable/widget_home_slab.xml | 9 + .../main/res/drawable/widget_home_wait.xml | 14 + .../main/res/layout/widget_home_preview.xml | 85 +++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/widget_home_info.xml | 17 + .../app/widget/WidgetLaunchTest.kt | 4 +- .../app/widget/WidgetPlaybackTest.kt | 7 +- 18 files changed, 793 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetStore.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidgetReceiver.kt create mode 100644 app/src/main/res/drawable/widget_home_open.xml create mode 100644 app/src/main/res/drawable/widget_home_pause.xml create mode 100644 app/src/main/res/drawable/widget_home_play.xml create mode 100644 app/src/main/res/drawable/widget_home_rule.xml create mode 100644 app/src/main/res/drawable/widget_home_slab.xml create mode 100644 app/src/main/res/drawable/widget_home_wait.xml create mode 100644 app/src/main/res/layout/widget_home_preview.xml create mode 100644 app/src/main/res/xml/widget_home_info.xml 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 @@ + + + + + + + + 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/WidgetLaunch.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt index a55cbbb4..696a5246 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,6 +50,7 @@ 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 } } @@ -65,6 +71,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 +88,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..b425484a 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt @@ -68,7 +68,11 @@ internal fun binderPlayPlan( else -> 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?, @@ -77,12 +81,12 @@ internal fun togglePlan( shownBookId: String?, shownPlayable: Boolean, ): WidgetPlayPlan = when { - !isUnlocked -> WidgetPlayPlan.IGNORE + !isUnlocked -> WidgetPlayPlan.OPEN_UNLOCK 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 +232,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 +248,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 +296,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 +341,24 @@ 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 - } + 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 = togglePlan( - isUnlocked = unlocked, + isUnlocked = true, currentBookId = playbackManager.currentBook.value?.id, state = playbackManager.playbackState.value, bookLoading = playbackManager.loadingBook.value != null, shownBookId = shown?.id, 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/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..9cb30799 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -0,0 +1,304 @@ +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(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) + } + } +} + +/** 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), + ) + } + } + } + } +} + +@Composable +private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float) { + val context = LocalContext.current + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + val rank = model.rank + if (layout != HomeLayout.CURRENT_ONLY && rank != null) { + RankSlab(rank, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) + Spacer(GlanceModifier.height(10.dp)) + } + model.current?.let { CurrentBook(it, art.current, compact = layout == HomeLayout.CURRENT_ONLY) } + val lives = model.lives.take(livesSlotsFor(widthDp)) + if (layout == HomeLayout.FULL && lives.isNotEmpty()) { + Spacer(GlanceModifier.height(10.dp)) + Image( + provider = ImageProvider(R.drawable.widget_home_rule), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = GlanceModifier.fillMaxWidth().height(1.dp), + ) + Spacer(GlanceModifier.height(10.dp)) + Row(modifier = GlanceModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + lives.forEachIndexed { i, life -> + if (i > 0) Spacer(GlanceModifier.width(10.dp)) + Cover( + bitmap = art.lives[life.bookId], + label = life.title, + sizeDp = 48, + action = openBook(context, life.bookId), + ) + } + } + } + } +} + +/** 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) 56 else 64, action = open) + Spacer(GlanceModifier.width(10.dp)) + Column( + modifier = GlanceModifier.defaultWeight().clickable(open), + verticalAlignment = Alignment.CenterVertically, + ) { + 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)) + } + } + Spacer(GlanceModifier.width(8.dp)) + 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(44.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..a28d6e8d --- /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..3aaf72bc --- /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/widget/WidgetLaunchTest.kt b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt index 0a02f307..4cdef525 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,6 @@ 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"))) } } 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..8124f8b5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt @@ -38,6 +38,10 @@ 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)) @@ -45,7 +49,8 @@ class WidgetPlaybackTest { @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)) } From db37e5cf965770802b40171394bb000d7d186618 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:24:34 -0400 Subject: [PATCH 04/11] The Nine Lives widget redraws on play, pause, chapter and book changes, and every few minutes while playing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../ninelivesaudio/app/widget/WidgetRefresher.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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..d52863b5 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() @@ -138,7 +143,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 +151,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}") } + } } } From b38d7deff9423ae77cf19c1ba88b9153a2f2dfc0 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:33:36 -0400 Subject: [PATCH 05/11] The Nine Lives picker preview loads (no plain View in a widget layout) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- app/src/main/res/layout/widget_home_preview.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/widget_home_preview.xml b/app/src/main/res/layout/widget_home_preview.xml index a28d6e8d..33e28e8e 100644 --- a/app/src/main/res/layout/widget_home_preview.xml +++ b/app/src/main/res/layout/widget_home_preview.xml @@ -39,7 +39,7 @@ android:importantForAccessibility="no" android:src="@drawable/widget_sleeve_empty" /> - @@ -51,7 +51,7 @@ android:src="@drawable/widget_home_play" /> - Date: Sun, 1 Nov 2026 01:42:59 -0400 Subject: [PATCH 06/11] A widget tap for a book opens that book, even when another book's page is open Navigation's single top matched the book page's route pattern, so with book A's page on top a widget tap for book B kept showing book A. This hit the Nine Lives widget's Lives and the Binder's Open its page alike. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- app/src/main/java/com/ninelivesaudio/app/MainActivity.kt | 8 ++++++-- .../java/com/ninelivesaudio/app/widget/WidgetLaunch.kt | 8 ++++++++ .../com/ninelivesaudio/app/widget/WidgetLaunchTest.kt | 8 ++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt index 1e554adf..b25013bd 100644 --- a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt @@ -73,6 +73,7 @@ import com.ninelivesaudio.app.ui.navigation.NineLivesNavHost import com.ninelivesaudio.app.ui.navigation.Routes import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme import com.ninelivesaudio.app.widget.WidgetRoute +import com.ninelivesaudio.app.widget.opensNewBookPage import com.ninelivesaudio.app.widget.widgetLaunchOrNull import com.ninelivesaudio.app.widget.widgetRoute import dagger.hilt.android.AndroidEntryPoint @@ -268,8 +269,11 @@ class MainActivity : ComponentActivity() { pendingWidgetRoute.value = null if (!appSettings.onboardingComplete) return@LaunchedEffect when (route) { - is WidgetRoute.Book -> 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/widget/WidgetLaunch.kt b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt index 696a5246..1335f370 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt @@ -55,6 +55,14 @@ internal fun parseWidgetLaunch(action: String?, bookId: String?): WidgetLaunch? } } +/** + * 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 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 4cdef525..6afaeed3 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetLaunchTest.kt @@ -29,4 +29,12 @@ class WidgetLaunchTest { 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")) + } } From a539c542170b9aa8841cdfca0d6442f174b5cc1c Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:50:44 -0400 Subject: [PATCH 07/11] The Nine Lives widget fits a 2x2: cover and play on top, title below, and the resize floor matches real launcher cells Pixel Launcher rounds a 2x2 to about 172 by 224 dp, under the old 180 dp floor, so the smallest size could not be reached. The bitmap budget now counts only the covers each size draws. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../app/widget/home/HomeWidgetLayout.kt | 18 ++++- .../app/widget/home/NineLivesHomeWidget.kt | 74 ++++++++++++++----- app/src/main/res/xml/widget_home_info.xml | 4 +- .../app/widget/home/HomeWidgetLayoutTest.kt | 16 +++- 4 files changed, 86 insertions(+), 26 deletions(-) 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 index 955383ec..672c3a73 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -29,14 +29,26 @@ 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). + * 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(180f to 110f, 250f to 180f, 250f to 260f, 300f to 260f) + 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.size * (rgb565Bytes(HOME_CURRENT_COVER_PX) + MAX_LIVES * rgb565Bytes(HOME_LIFE_COVER_PX)) + HOME_WIDGET_SIZES.sumOf { (w, h) -> homeBucketBitmapBytes(w, h) } 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 index 9cb30799..5a45fc2c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -109,14 +109,14 @@ private fun HomeWidgetFace(snapshot: HomeWidgetSnapshot?) { .cornerRadius(16.dp) .clickable(actionStartActivity(widgetMainIntent(context, rootLaunch))) .semantics { contentDescription = rootLabel } - .padding(12.dp), + .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) + else -> Ready(model, snapshot.art, layout, size.width.value, isTallCurrentOnly(size.width.value, size.height.value)) } } } @@ -170,7 +170,7 @@ private fun Message(line: String?, locked: Boolean, layout: HomeLayout) { } @Composable -private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float) { +private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout, widthDp: Float, tall: Boolean) { val context = LocalContext.current Column( modifier = GlanceModifier.fillMaxSize(), @@ -181,7 +181,9 @@ private fun Ready(model: HomeWidgetModel, art: HomeWidgetArt, layout: HomeLayout RankSlab(rank, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) Spacer(GlanceModifier.height(10.dp)) } - model.current?.let { CurrentBook(it, art.current, compact = layout == HomeLayout.CURRENT_ONLY) } + model.current?.let { + if (tall) CurrentBookStacked(it, art.current) else CurrentBook(it, art.current, compact = layout == HomeLayout.CURRENT_ONLY) + } val lives = model.lives.take(livesSlotsFor(widthDp)) if (layout == HomeLayout.FULL && lives.isNotEmpty()) { Spacer(GlanceModifier.height(10.dp)) @@ -247,8 +249,8 @@ private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, c 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) 56 else 64, action = open) - Spacer(GlanceModifier.width(10.dp)) + 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, @@ -256,30 +258,62 @@ private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, c Text( text = current.title, maxLines = 2, - style = TextStyle(color = textPrimary, fontSize = 14.sp, fontWeight = FontWeight.Medium), + 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(8.dp)) - 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) + 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 on top, the title and chapter under them. */ +@Composable +private fun CurrentBookStacked(current: CurrentCard, cover: android.graphics.Bitmap?) { + val context = LocalContext.current + val open = openBook(context, current.bookId) + Column(modifier = GlanceModifier.fillMaxWidth()) { + 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)) + 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)) + } } - Image( - provider = ImageProvider(icon), - contentDescription = label, - modifier = GlanceModifier.size(44.dp).clickable(action), - ) } } +@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 diff --git a/app/src/main/res/xml/widget_home_info.xml b/app/src/main/res/xml/widget_home_info.xml index 3aaf72bc..973179f0 100644 --- a/app/src/main/res/xml/widget_home_info.xml +++ b/app/src/main/res/xml/widget_home_info.xml @@ -6,8 +6,8 @@ android:initialLayout="@layout/glance_default_loading_layout" android:minHeight="180dp" android:minWidth="250dp" - android:minResizeHeight="110dp" - android:minResizeWidth="180dp" + android:minResizeHeight="80dp" + android:minResizeWidth="160dp" android:previewImage="@drawable/widget_eye" android:previewLayout="@layout/widget_home_preview" android:resizeMode="horizontal|vertical" 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 index 742a79b4..f13a8be5 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -16,11 +16,16 @@ class HomeWidgetLayoutTest { @Test fun `every responsive bucket maps to a layout that keeps play`() { assertEquals( - listOf(HomeLayout.CURRENT_ONLY, HomeLayout.RANK_AND_CURRENT, HomeLayout.FULL, HomeLayout.FULL), + 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)) @@ -35,5 +40,14 @@ class HomeWidgetLayoutTest { @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 * 96 * 96 * 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)) } } From 695f35963f7c69cffd14642a7325d334dd5aded7 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:33:21 -0500 Subject: [PATCH 08/11] The large widget redraws when a book load ends, so a failed load no longer leaves the dimmed play button Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../ninelivesaudio/app/widget/WidgetRefreshTriggers.kt | 10 ++++++++-- .../com/ninelivesaudio/app/widget/WidgetRefresher.kt | 10 ++++++---- .../app/widget/WidgetRefreshTriggersTest.kt | 8 ++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) 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 d52863b5..17f7ff64 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetRefresher.kt @@ -105,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 } @@ -115,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], 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())) } From 8df9be63f83f15c8f6db8058e38cacf4d1b005a3 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:33:21 -0500 Subject: [PATCH 09/11] The large widget fills its space: rank at the top, Lives along the bottom spanning the full width, the current book between Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../ninelivesaudio/app/widget/WidgetCovers.kt | 2 +- .../app/widget/home/HomeWidgetLayout.kt | 13 ++++ .../app/widget/home/NineLivesHomeWidget.kt | 68 ++++++++++++------- .../app/widget/home/HomeWidgetLayoutTest.kt | 17 ++++- 4 files changed, 74 insertions(+), 26 deletions(-) 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/home/HomeWidgetLayout.kt b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt index 672c3a73..bc2726aa 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayout.kt @@ -22,6 +22,19 @@ internal fun homeLayoutFor(widthDp: Float, heightDp: Float): HomeLayout = when { /** 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 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 index 5a45fc2c..72799865 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/home/NineLivesHomeWidget.kt @@ -169,40 +169,59 @@ private fun Message(line: String?, locked: Boolean, layout: HomeLayout) { } } +/** + * 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 - Column( - modifier = GlanceModifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - ) { - val rank = model.rank - if (layout != HomeLayout.CURRENT_ONLY && rank != null) { - RankSlab(rank, actionStartActivity(widgetMainIntent(context, WidgetLaunch.OpenApp))) - Spacer(GlanceModifier.height(10.dp)) - } - model.current?.let { - if (tall) CurrentBookStacked(it, art.current) else CurrentBook(it, art.current, compact = layout == HomeLayout.CURRENT_ONLY) + 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) + } } - val lives = model.lives.take(livesSlotsFor(widthDp)) + 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()) { - Spacer(GlanceModifier.height(10.dp)) Image( provider = ImageProvider(R.drawable.widget_home_rule), contentDescription = null, contentScale = ContentScale.FillBounds, modifier = GlanceModifier.fillMaxWidth().height(1.dp), ) - Spacer(GlanceModifier.height(10.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) { - lives.forEachIndexed { i, life -> - if (i > 0) Spacer(GlanceModifier.width(10.dp)) - Cover( - bitmap = art.lives[life.bookId], - label = life.title, - sizeDp = 48, - action = openBook(context, life.bookId), - ) + 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)) + } } } } @@ -270,18 +289,19 @@ private fun CurrentBook(current: CurrentCard, cover: android.graphics.Bitmap?, c } } -/** A 2x2: cover and play side by side on top, the title and chapter under them. */ +/** 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.fillMaxWidth()) { + 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, 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 index f13a8be5..fa2ac0be 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/home/HomeWidgetLayoutTest.kt @@ -42,7 +42,7 @@ class HomeWidgetLayoutTest { 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 * 96 * 96 * 2, homeBucketBitmapBytes(300f, 260f)) + 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`() { @@ -50,4 +50,19 @@ class HomeWidgetLayoutTest { 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) + } + } } From 3f9d7e54640d77e57201cba9c2f75cce2854af82 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 01:56:56 -0500 Subject: [PATCH 10/11] A stale widget play button no longer pauses or resumes the new library's book Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../java/com/ninelivesaudio/app/widget/WidgetPlayback.kt | 5 +++++ .../com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt | 6 ++++++ 2 files changed, 11 insertions(+) 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 b425484a..fea3f88d 100644 --- a/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt +++ b/app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt @@ -80,8 +80,12 @@ internal fun togglePlan( bookLoading: Boolean, shownBookId: String?, shownPlayable: Boolean, + shownInActiveLibrary: Boolean = true, ): WidgetPlayPlan = when { !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 @@ -355,6 +359,7 @@ class WidgetPlayback @Inject constructor( bookLoading = playbackManager.loadingBook.value != null, shownBookId = shown?.id, shownPlayable = shown != null && isPlayable(shown), + shownInActiveLibrary = shown != null, ) launchAct(plan, shown, act) plan 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 8124f8b5..541eab1c 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt @@ -47,6 +47,12 @@ class WidgetPlaybackTest { 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 `with nothing retained play loads the book the widget shows`() { assertEquals(WidgetPlayPlan.LOAD_BOOK, togglePlan(true, null, PlaybackState.STOPPED, false, shownBookId = "b3", shownPlayable = true)) // Like a Binder cover: a book that cannot play right now opens its page, which says why. From 397cc970beac299c8dc87813cdae618fe7870372 Mon Sep 17 00:00:00 2001 From: Static Date: Sun, 1 Nov 2026 02:08:14 -0500 Subject: [PATCH 11/11] Pausing from the widget no longer waits on the server reachability check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014XfNSbEfq8dG2ResrdGXbf --- .../app/widget/WidgetPlayback.kt | 26 +++++++++++++++---- .../app/widget/WidgetPlaybackTest.kt | 12 +++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) 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 fea3f88d..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 Boolean, ): WidgetPlayPlan = when { !isUnlocked -> WidgetPlayPlan.OPEN_UNLOCK // A stale button (widget not yet redrawn after a library or mode switch) @@ -90,7 +106,7 @@ internal fun togglePlan( isAudible(state) -> WidgetPlayPlan.PAUSE currentBookId != null -> WidgetPlayPlan.RESUME_CURRENT shownBookId == null -> WidgetPlayPlan.IGNORE - !shownPlayable -> WidgetPlayPlan.OPEN_BOOK_PAGE + !shownPlayable() -> WidgetPlayPlan.OPEN_BOOK_PAGE else -> WidgetPlayPlan.LOAD_BOOK } @@ -352,14 +368,14 @@ class WidgetPlayback @Inject constructor( // 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 = togglePlan( + 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, act) plan 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 541eab1c..35c758dc 100644 --- a/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt +++ b/app/src/test/java/com/ninelivesaudio/app/widget/WidgetPlaybackTest.kt @@ -53,6 +53,18 @@ class WidgetPlaybackTest { 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)) // Like a Binder cover: a book that cannot play right now opens its page, which says why.