Skip to content

Commit be71067

Browse files
authored
fix(audio-bible): restore audio bar + toolbar indicator on activity reattach and return-from-background (#219)
* fix(audio-bible): restore audio bar + toolbar indicator on activity reattach and return-from-background The audio service survives activity recreation and backgrounding, but the audio bar reset to hidden on the recreated/returning activity, so a playing session lost its UI surface (Issue 5: rotation hid the bar; Issue 6: return from background lost the bar + verse marker while audio kept playing). IsiActivity.onStart now calls AudioBarController.reshowIfSessionActive(), which binds to the service only when it reports an active session (BibleAudioService.hasActiveSession) and re-shows the bar once the first active PlaybackState arrives — re-synced via the existing StateFlow collection, no flicker, no service spin-up for users who never started audio. The toolbar menuAudio active icon follows the restored bar visibility. The service stays the source of truth; no onSaveInstanceState added. * refactor(audio-bible): consolidate pending-reshow handling into a single one-shot branch
1 parent f5385bf commit be71067

5 files changed

Lines changed: 149 additions & 0 deletions

File tree

Alkitab/src/main/java/yuku/alkitab/base/IsiActivity.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,11 @@ class IsiActivity : BaseLeftDrawerActivity(), LeftDrawer.Text.Listener, VerseAct
11441144
// Re-resolve the audio overlay color in case the user changed the
11451145
// reading theme via the textAppearancePanel while we were stopped.
11461146
audioHighlightColorCached = 0
1147+
1148+
// Restore the audio bar + toolbar indicator if the service kept playing
1149+
// across recreation (rotation) or while we were backgrounded. Covers
1150+
// both a freshly recreated activity and a return on the same instance.
1151+
audioBinder.reshowIfSessionActive()
11471152
}
11481153

11491154
override fun onDestroy() {

Alkitab/src/main/java/yuku/alkitab/base/audio/AudioBarController.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,15 @@ class AudioBarController(
109109
private var bound = false
110110
/** Tracks whether the user has *requested* the bar visible (via [toggle]). */
111111
private var requestedVisible = false
112+
/**
113+
* Set by [reshowIfSessionActive] while we are binding to an
114+
* already-running service purely to restore the bar after activity
115+
* recreation / return-from-background. The first projected
116+
* [PlaybackState] that reports [PlaybackState.isActive] flips the bar back
117+
* on, then clears this flag. Cleared without showing if the session has
118+
* ended by the time we connect (no flicker).
119+
*/
120+
private var reshowPending = false
112121
/**
113122
* Set while the user has the slider thumb under their finger. Drives a
114123
* special-case in [projectToUi]: live playback continues to push a
@@ -211,6 +220,23 @@ class AudioBarController(
211220
this.composeView = composeView
212221
}
213222

223+
/**
224+
* Restores the audio bar when the activity becomes visible again (rotation,
225+
* process/activity recreation, or return-from-background) while the service
226+
* is still mid-session. Call from `IsiActivity.onStart` — it covers both a
227+
* fresh activity (after [attach]) and a returning one on the same instance.
228+
*
229+
* Binds only when [BibleAudioService.hasActiveSession] is already true, so
230+
* we never spin the service up for users who haven't started audio. The
231+
* actual reshow happens in [projectToUi] once the first [PlaybackState]
232+
* arrives, avoiding a show-then-hide flicker if the session just ended.
233+
*/
234+
fun reshowIfSessionActive() {
235+
if (!shouldBindForReshow(requestedVisible, BibleAudioService.hasActiveSession)) return
236+
reshowPending = true
237+
ensureBound()
238+
}
239+
214240
private var composeContentInstalled = false
215241

216242
private fun ensureComposeContent() {
@@ -357,6 +383,7 @@ class AudioBarController(
357383

358384
fun hide() {
359385
requestedVisible = false
386+
reshowPending = false
360387
dragging = false
361388
selectedSource = null
362389
startVerse1 = 0
@@ -390,6 +417,7 @@ class AudioBarController(
390417
* may continue playing when the activity is recreated (M4 lock-screen).
391418
*/
392419
fun detach() {
420+
reshowPending = false
393421
if (bound) {
394422
try {
395423
context.unbindService(serviceConnection)
@@ -523,6 +551,29 @@ class AudioBarController(
523551

524552
private fun projectToUi(state: PlaybackState) {
525553
val host = this.host
554+
555+
// Auto-reshow after activity recreation / return-from-background: the
556+
// service is still mid-session but the bar was reset to hidden. Flip it
557+
// back on once the first active state lands, reconstructing the session
558+
// source from the service's loaded versionId. Done before the
559+
// _uiState.update below so `visible` picks it up in the same emission.
560+
if (reshowPending) {
561+
val reshow = shouldReshowNow(reshowPending, state)
562+
// One-shot: consume the flag on the first state after binding,
563+
// whether or not we actually reshow. If the session ended before we
564+
// connected (!state.isActive) we just drop it — no show-then-hide
565+
// flicker against an idle service.
566+
reshowPending = false
567+
if (reshow) {
568+
requestedVisible = true
569+
ensureComposeContent()
570+
host?.audioAvailableSources()
571+
?.firstOrNull { it.versionId == state.versionId }
572+
?.let { selectedSource = it }
573+
host?.audioBarVisibilityChanged(true)
574+
}
575+
}
576+
526577
// Snapshot before we drain — if a load was queued before the service
527578
// connected, the first incoming state is usually `IDLE`, which would
528579
// briefly clear the spinner before our loadChapter call sets it back
@@ -597,5 +648,20 @@ class AudioBarController(
597648

598649
companion object {
599650
private const val TAG = "AudioBarController"
651+
652+
/**
653+
* Whether [reshowIfSessionActive] should bind to the service: only when
654+
* the bar isn't already managed by this controller ([requestedVisible])
655+
* and the service reports an active session. Pure for unit testing.
656+
*/
657+
internal fun shouldBindForReshow(requestedVisible: Boolean, hasActiveSession: Boolean): Boolean =
658+
!requestedVisible && hasActiveSession
659+
660+
/**
661+
* Whether a pending reshow should fire for [state]: only once the first
662+
* active state arrives. Pure for unit testing.
663+
*/
664+
internal fun shouldReshowNow(reshowPending: Boolean, state: PlaybackState): Boolean =
665+
reshowPending && state.isActive
600666
}
601667
}

Alkitab/src/main/java/yuku/alkitab/base/audio/BibleAudioService.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ class BibleAudioService : MediaSessionService() {
9090
private const val DEFAULT_PLAYBACK_SPEED = 1.0f
9191

9292
private const val TAG = "BibleAudioService"
93+
94+
/**
95+
* Process-wide flag (the service is local and single-instance) that lets
96+
* [AudioBarController] decide whether to re-show the audio bar after the
97+
* activity is recreated or returns from the background — *without*
98+
* binding (which would spin the service up via `BIND_AUTO_CREATE` for
99+
* users who never started audio). Set true while a chapter is loaded,
100+
* cleared on stop / destroy.
101+
*/
102+
@Volatile
103+
var hasActiveSession: Boolean = false
104+
private set
93105
}
94106

95107
/** Parameters for [loadChapter]. The display fields drive the lock-screen metadata. */
@@ -304,6 +316,7 @@ class BibleAudioService : MediaSessionService() {
304316
}
305317

306318
override fun onDestroy() {
319+
hasActiveSession = false
307320
positionJob?.cancel()
308321
loadJob?.cancel()
309322
timingJob?.cancel()
@@ -326,6 +339,7 @@ class BibleAudioService : MediaSessionService() {
326339
loadJob?.cancel()
327340
timingJob?.cancel()
328341
currentRequest = request
342+
hasActiveSession = true
329343
pendingStartVerse1 = request.startVerse_1
330344
playerReadyForSeek = false
331345
timingLoaded = false
@@ -487,6 +501,7 @@ class BibleAudioService : MediaSessionService() {
487501
timingJob?.cancel()
488502
positionJob?.cancel()
489503
currentRequest = null
504+
hasActiveSession = false
490505
player.pause()
491506
_playbackState.value = PlaybackState.IDLE
492507
stopSelf()

Alkitab/src/main/java/yuku/alkitab/base/audio/PlaybackState.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ data class PlaybackState(
3939
val speed: Float,
4040
val error: String?,
4141
) {
42+
/**
43+
* True while a chapter is loaded into the service (playing, paused, or
44+
* buffering) — i.e. not [IDLE]/stopped. Drives the auto-reshow decision in
45+
* [AudioBarController] when the activity is recreated or returns from the
46+
* background. Mirrors the `bookId >= 0` invariant that `loadChapter` sets
47+
* and `stop()` clears.
48+
*/
49+
val isActive: Boolean
50+
get() = bookId >= 0
51+
4252
companion object {
4353
val IDLE = PlaybackState(
4454
isPlaying = false,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package yuku.alkitab.base.audio
2+
3+
import org.junit.Assert.assertFalse
4+
import org.junit.Assert.assertTrue
5+
import org.junit.Test
6+
7+
/**
8+
* Pure-logic tests for the audio-bar auto-reshow decision used after the
9+
* activity is recreated (rotation) or returns from the background while the
10+
* [BibleAudioService] keeps playing. See [AudioBarController.reshowIfSessionActive].
11+
*/
12+
class AudioBarControllerReshowTest {
13+
14+
private fun state(bookId: Int): PlaybackState =
15+
PlaybackState.IDLE.copy(bookId = bookId, chapter_1 = 1, versionId = "preset/in-tb")
16+
17+
@Test
18+
fun `reshow binds when the service is mid-session and the bar is not already managed`() {
19+
assertTrue(AudioBarController.shouldBindForReshow(requestedVisible = false, hasActiveSession = true))
20+
}
21+
22+
@Test
23+
fun `reshow does not bind when the service is idle`() {
24+
assertFalse(AudioBarController.shouldBindForReshow(requestedVisible = false, hasActiveSession = false))
25+
}
26+
27+
@Test
28+
fun `reshow does not bind when the bar is already shown by this controller`() {
29+
assertFalse(AudioBarController.shouldBindForReshow(requestedVisible = true, hasActiveSession = true))
30+
}
31+
32+
@Test
33+
fun `pending reshow fires once an active state arrives`() {
34+
assertTrue(AudioBarController.shouldReshowNow(reshowPending = true, state = state(bookId = 5)))
35+
}
36+
37+
@Test
38+
fun `pending reshow stays hidden against an idle state to avoid show-then-hide flicker`() {
39+
assertFalse(AudioBarController.shouldReshowNow(reshowPending = true, state = state(bookId = -1)))
40+
}
41+
42+
@Test
43+
fun `no reshow when none is pending even if the state is active`() {
44+
assertFalse(AudioBarController.shouldReshowNow(reshowPending = false, state = state(bookId = 5)))
45+
}
46+
47+
@Test
48+
fun `PlaybackState reports active once a chapter is loaded and idle otherwise`() {
49+
assertFalse(PlaybackState.IDLE.isActive)
50+
assertTrue(state(bookId = 0).isActive)
51+
assertTrue(state(bookId = 41).isActive)
52+
}
53+
}

0 commit comments

Comments
 (0)