Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@
</intent-filter>
</activity>

<!-- The Nine Lives large home screen widget (Glance): rank, current book with play and pause, next Lives. -->
<receiver
android:name=".widget.home.NineLivesHomeWidgetReceiver"
android:exported="false"
android:label="@string/widget_home_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_home_info" />
</receiver>

<!-- Media3 Playback Service for background audio + Android Auto browsing -->
<service
android:name=".service.PlaybackService"
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/com/ninelivesaudio/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 3 additions & 14 deletions app/src/main/java/com/ninelivesaudio/app/ui/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ""
Expand Down
Original file line number Diff line number Diff line change
@@ -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>): Double = currentTimesSeconds.sum()
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class WidgetActionActivity : ComponentActivity() {
val (bookId, tap) = when (launch) {
is WidgetLaunch.PlayBook -> launch.bookId to widgetPlayback.binderTap(launch)
is WidgetLaunch.OpenBook -> launch.bookId to widgetPlayback.binderTap(launch)
is WidgetLaunch.TogglePlay -> launch.bookId to widgetPlayback.toggleTap(launch)
null, WidgetLaunch.Nothing -> {
finish()
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/com/ninelivesaudio/app/widget/WidgetLaunch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}

Expand All @@ -45,10 +50,19 @@ internal fun parseWidgetLaunch(action: String?, bookId: String?): WidgetLaunch?
"empty" -> WidgetLaunch.Nothing
"open_book" -> id?.let { WidgetLaunch.OpenBook(it) }
"play_book" -> id?.let { WidgetLaunch.PlayBook(it) }
"toggle_play" -> id?.let { WidgetLaunch.TogglePlay(it) }
else -> null
}
}

/**
* Whether a widget's Open tap needs a new book page. Only the same book
* already on top is left alone. Navigation's single top matches the route
* pattern, not the book, so it would keep another book's page instead.
*/
internal fun opensNewBookPage(topRoute: String?, topBookId: String?, bookId: String): Boolean =
!(topRoute == com.ninelivesaudio.app.ui.navigation.Routes.BOOK_DETAIL && topBookId == bookId)

/** The screen `MainActivity` opens for a widget launch. */
internal sealed interface WidgetRoute {
data class Book(val bookId: String) : WidgetRoute
Expand All @@ -65,6 +79,7 @@ internal fun widgetRoute(launch: WidgetLaunch): WidgetRoute? = when (launch) {
is WidgetLaunch.OpenBook -> WidgetRoute.Book(launch.bookId)
WidgetLaunch.OpenUnlock -> WidgetRoute.Unlock
is WidgetLaunch.PlayBook -> null
is WidgetLaunch.TogglePlay -> null
}

internal fun Intent.putWidgetLaunch(launch: WidgetLaunch): Intent = apply {
Expand All @@ -81,6 +96,16 @@ internal fun widgetMainIntent(context: Context, launch: WidgetLaunch): Intent =
.putWidgetLaunch(launch)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)

/**
* A tap the trampoline decides (Play, Open its page, play or pause), as a
* finished intent. The data URI keeps each launch's PendingIntent its own,
* since Android ignores extras when it matches PendingIntents.
*/
internal fun widgetActionIntent(context: Context, launch: WidgetLaunch): Intent =
widgetActionIntent(context)
.putWidgetLaunch(launch)
.setData(android.net.Uri.parse("ninelives-widget://${launch.actionName()}/${launch.bookIdOrNull.orEmpty()}"))

/** The Binder's fill in template. Each cover adds its own extras. */
internal fun widgetActionIntent(context: Context): Intent =
Intent(context, WidgetActionActivity::class.java)
81 changes: 58 additions & 23 deletions app/src/main/java/com/ninelivesaudio/app/widget/WidgetPlayback.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ internal class WidgetTap(val plan: Deferred<WidgetPlayPlan>, val act: Deferred<U
/** What a widget play tap turns into. */
internal enum class WidgetPlayPlan { IGNORE, PAUSE, RESUME_CURRENT, LOAD_BOOK, OPEN_BOOK_PAGE, OPEN_UNLOCK }

private fun isLoading(state: PlaybackState, bookLoading: Boolean) =
internal fun isLoading(state: PlaybackState, bookLoading: Boolean) =
bookLoading || state == PlaybackState.LOADING

private fun isAudible(state: PlaybackState) =
internal fun isAudible(state: PlaybackState) =
state == PlaybackState.PLAYING || state == PlaybackState.BUFFERING

/**
Expand All @@ -68,21 +68,45 @@ 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?,
state: PlaybackState,
bookLoading: Boolean,
shownBookId: String?,
shownPlayable: Boolean,
shownInActiveLibrary: Boolean = true,
): WidgetPlayPlan = togglePlanLazy(
isUnlocked, currentBookId, state, bookLoading, shownBookId, shownInActiveLibrary,
) { shownPlayable }

/**
* Same decision as [togglePlan], but playability is only asked for when the
* tap would load a book. Pausing never waits on a server probe.
*/
internal inline fun togglePlanLazy(
isUnlocked: Boolean,
currentBookId: String?,
state: PlaybackState,
bookLoading: Boolean,
shownBookId: String?,
shownInActiveLibrary: Boolean = true,
shownPlayable: () -> Boolean,
): WidgetPlayPlan = when {
!isUnlocked -> WidgetPlayPlan.IGNORE
!isUnlocked -> WidgetPlayPlan.OPEN_UNLOCK
// A stale button (widget not yet redrawn after a library or mode switch)
// never touches the new library's playback.
!shownInActiveLibrary -> WidgetPlayPlan.IGNORE
isLoading(state, bookLoading) -> WidgetPlayPlan.IGNORE
isAudible(state) -> WidgetPlayPlan.PAUSE
currentBookId != null -> WidgetPlayPlan.RESUME_CURRENT
shownBookId == null -> WidgetPlayPlan.IGNORE
!shownPlayable -> WidgetPlayPlan.IGNORE
!shownPlayable() -> WidgetPlayPlan.OPEN_BOOK_PAGE
else -> WidgetPlayPlan.LOAD_BOOK
}

Expand Down Expand Up @@ -228,8 +252,10 @@ class WidgetPlayback @Inject constructor(
* Book Detail's Play gates: not archived, source reachable, remote media
* not blocked. A streamed book that reads blocked gets one server check
* first (see [settledConnectionStatus]), since a cold process has not run one.
* A redraw passes [settle] false: it reads the status as it is, and the
* check landing later redraws the widget anyway.
*/
internal suspend fun isPlayable(book: AudioBook): Boolean {
internal suspend fun isPlayable(book: AudioBook, settle: Boolean = true): Boolean {
val library = book.libraryId?.let { libraryRepository.getById(it) }
val access = reconcileLocalBookAccess(
book,
Expand All @@ -242,6 +268,7 @@ class WidgetPlayback @Inject constructor(
connectionStatus = status,
)
if (decision(connectivityMonitor.connectionStatus.value) !is RemoteMediaAccessDecision.Blocked) return true
if (!settle) return false
val status = settledConnectionStatus(current = { connectivityMonitor.connectionStatus.value }) {
connectivityMonitor.refreshIsOnlineFromSystem()
withTimeoutOrNull(WIDGET_SERVER_CHECK_WAIT) { connectivityMonitor.checkServerReachable() }
Expand Down Expand Up @@ -289,8 +316,17 @@ class WidgetPlayback @Inject constructor(
}
}

internal suspend fun toggle(shownBookId: String?): WidgetPlayPlan =
scope.async { toggleOwned(shownBookId) }.await()
/**
* The large widget's play or pause button. Decided on this class's scope
* like a Binder tap, so the handoff screen can hold for the foreground
* start (WidgetActionActivity) without owning the load.
*/
internal fun toggleTap(launch: WidgetLaunch.TogglePlay): WidgetTap {
val act = CompletableDeferred<Unit>()
val plan = scope.async { toggleOwned(launch.bookId, act) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decide pause actions before probing server reachability

When a streamed book is still playing or buffering after its server becomes unreachable, the new widget's pause tap calls toggleOwned, which eagerly awaits isPlayable(shown) before togglePlan can select PAUSE. That check can wait up to eight seconds for a server probe, leaving audio playing despite the pause tap even though pausing requires no network access. After validating entitlement and the active library, select the pause action before performing the playability check; reserve that check for actions that actually need to load media.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 397cc97. Playability is now asked lazily, only when the tap would load a book. A pause while audio is playing or buffering returns PAUSE without the server probe. The test uses a probe that hangs if called.

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? =
Expand Down Expand Up @@ -325,26 +361,25 @@ class WidgetPlayback @Inject constructor(
binderOpenPlan(isUnlocked = true, inActiveLibrary = activeBook(bookId) != null)
}

private suspend fun toggleOwned(shownBookId: String?): WidgetPlayPlan {
playbackManager.awaitStartupRestore()
val unlocked = effectiveSettings.isUnlockedNow
val shown = if (unlocked && shownBookId != null) {
withContext(Dispatchers.IO) { audioBookRepository.getById(shownBookId) }
?.takeIf { it.isInActiveLibrary(settingsManager.currentSettings) }
} else {
null
}
val plan = togglePlan(
isUnlocked = unlocked,
private suspend fun toggleOwned(shownBookId: String, act: CompletableDeferred<Unit>): WidgetPlayPlan = decideBinderTap(
isUnlocked = { effectiveSettings.isUnlockedNow },
awaitReady = { playbackManager.awaitStartupRestore() },
) {
// A tap from a widget not yet redrawn after a mode or account switch
// carries a book from the old library. It never plays or opens.
val shown = activeBook(shownBookId)
val plan = togglePlanLazy(
isUnlocked = true,
currentBookId = playbackManager.currentBook.value?.id,
state = playbackManager.playbackState.value,
bookLoading = playbackManager.loadingBook.value != null,
shownBookId = shown?.id,
shownPlayable = shown != null && isPlayable(shown),
shownInActiveLibrary = shown != null,
shownPlayable = { shown != null && isPlayable(shown) },
)
launchAct(plan, shown)
return plan
}
launchAct(plan, shown, act)
plan
}.also { if (it == WidgetPlayPlan.OPEN_UNLOCK) act.complete(Unit) }

private fun launchAct(plan: WidgetPlayPlan, book: AudioBook?, done: CompletableDeferred<Unit>? = null) {
Log.d(TAG, "plan $plan for ${book?.id}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<WidgetTarget> {
if (prev == null) return setOf(WidgetTarget.BINDER, WidgetTarget.HOME)
Expand All @@ -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
Expand Down
Loading
Loading