Skip to content

Commit 31ee129

Browse files
authored
Attempt at fixing media3 bugs (#413)
1 parent d8c3cd2 commit 31ee129

File tree

11 files changed

+289
-126
lines changed

11 files changed

+289
-126
lines changed

readium/lcp/src/main/java/org/readium/r2/lcp/LcpDownloadsRepository.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ package org.readium.r2.lcp
99
import android.content.Context
1010
import java.io.File
1111
import java.util.LinkedList
12+
import kotlinx.coroutines.CoroutineScope
1213
import kotlinx.coroutines.Deferred
1314
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.MainScope
1416
import kotlinx.coroutines.async
1517
import kotlinx.coroutines.launch
1618
import kotlinx.coroutines.withContext
@@ -20,31 +22,35 @@ import org.readium.r2.shared.util.CoroutineQueue
2022
internal class LcpDownloadsRepository(
2123
context: Context
2224
) {
23-
private val queue = CoroutineQueue()
25+
private val coroutineScope: CoroutineScope =
26+
MainScope()
27+
28+
private val queue: CoroutineQueue =
29+
CoroutineQueue()
2430

2531
private val storageDir: Deferred<File> =
26-
queue.scope.async {
32+
coroutineScope.async {
2733
withContext(Dispatchers.IO) {
2834
File(context.noBackupFilesDir, LcpDownloadsRepository::class.qualifiedName!!)
2935
.also { if (!it.exists()) it.mkdirs() }
3036
}
3137
}
3238

3339
private val storageFile: Deferred<File> =
34-
queue.scope.async {
40+
coroutineScope.async {
3541
withContext(Dispatchers.IO) {
3642
File(storageDir.await(), "licenses.json")
3743
.also { if (!it.exists()) { it.writeText("{}", Charsets.UTF_8) } }
3844
}
3945
}
4046

4147
private val snapshot: Deferred<MutableMap<String, JSONObject>> =
42-
queue.scope.async {
48+
coroutineScope.async {
4349
readSnapshot().toMutableMap()
4450
}
4551

4652
fun addDownload(id: String, license: JSONObject) {
47-
queue.scope.launch {
53+
coroutineScope.launch {
4854
val snapshotCompleted = snapshot.await()
4955
snapshotCompleted[id] = license
5056
writeSnapshot(snapshotCompleted)

readium/navigators/media/tts/src/main/java/org/readium/navigator/media/tts/TtsNavigator.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ public class TtsNavigator<S : TtsEngine.Settings, P : TtsEngine.Preferences<P>,
229229

230230
override fun close() {
231231
player.close()
232+
sessionAdapter.release()
232233
}
233234

234235
override val currentLocator: StateFlow<Locator> =

readium/navigators/media/tts/src/main/java/org/readium/navigator/media/tts/TtsPlayer.kt

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,26 @@ internal class TtsPlayer<S : TtsEngine.Settings, P : TtsEngine.Preferences<P>,
194194
}
195195

196196
fun play() {
197+
// This can be called by the session adapter with a pending intent for a foreground service
198+
// if playWhenReady is false or the state is Ended.
199+
// We must keep or transition to a state which will be translated by media3 to a
200+
// foreground state.
201+
// If the state was State.Ended, it will get back to its initial value later.
202+
203+
if (playbackMutable.value.playWhenReady && playback.value.state == State.Ready) {
204+
return
205+
}
206+
207+
playbackMutable.value =
208+
playbackMutable.value.copy(state = State.Ready, playWhenReady = true)
209+
197210
coroutineScope.launch {
198-
// WORKAROUND to get the media buttons correctly working.
199-
fakePlayingAudio()
200-
playAsync()
211+
mutex.withLock {
212+
// WORKAROUND to get the media buttons correctly working when an audio player was
213+
// running before.
214+
fakePlayingAudio()
215+
playIfReadyAndNotPaused()
216+
}
201217
}
202218
}
203219

@@ -240,30 +256,19 @@ internal class TtsPlayer<S : TtsEngine.Settings, P : TtsEngine.Preferences<P>,
240256
?: run { Timber.e("Couldn't fake playing audio.") }
241257
}
242258

243-
private suspend fun playAsync() = mutex.withLock {
244-
if (isPlaying()) {
245-
return
246-
}
247-
248-
playbackMutable.value = playbackMutable.value.copy(playWhenReady = true)
249-
playIfReadyAndNotPaused()
250-
}
251-
252259
fun pause() {
253-
coroutineScope.launch {
254-
pauseAsync()
255-
}
256-
}
257-
258-
private suspend fun pauseAsync() = mutex.withLock {
259260
if (!playbackMutable.value.playWhenReady) {
260261
return
261262
}
262263

263264
playbackMutable.value = playbackMutable.value.copy(playWhenReady = false)
264265
utteranceMutable.value = utteranceMutable.value.copy(range = null)
265-
playbackJob?.cancelAndJoin()
266-
Unit
266+
267+
coroutineScope.launch {
268+
mutex.withLock {
269+
playbackJob?.cancelAndJoin()
270+
}
271+
}
267272
}
268273

269274
fun tryRecover() {
@@ -308,19 +313,16 @@ internal class TtsPlayer<S : TtsEngine.Settings, P : TtsEngine.Preferences<P>,
308313
}
309314

310315
fun restartUtterance() {
311-
coroutineScope.launch {
312-
restartUtteranceAsync()
313-
}
314-
}
315-
316-
private suspend fun restartUtteranceAsync() = mutex.withLock {
317-
playbackJob?.cancel()
318316
if (playbackMutable.value.state == State.Ended) {
319317
playbackMutable.value = playbackMutable.value.copy(state = State.Ready)
320318
}
321-
utteranceMutable.value = utteranceMutable.value.copy(range = null)
322-
playbackJob?.join()
323-
playIfReadyAndNotPaused()
319+
320+
coroutineScope.launch {
321+
playbackJob?.cancel()
322+
playbackJob?.join()
323+
utteranceMutable.value = utteranceMutable.value.copy(range = null)
324+
playIfReadyAndNotPaused()
325+
}
324326
}
325327

326328
fun hasNextUtterance() =
@@ -541,9 +543,6 @@ internal class TtsPlayer<S : TtsEngine.Settings, P : TtsEngine.Preferences<P>,
541543
contentIterator.overrideContentLanguage = engineFacade.settings.value.overrideContentLanguage
542544
}
543545

544-
private fun isPlaying() =
545-
playbackMutable.value.playWhenReady && playback.value.state == State.Ready
546-
547546
private fun TtsUtteranceIterator.Utterance.ttsPlayerUtterance(): Utterance =
548547
Utterance(
549548
text = utterance,

readium/shared/src/main/java/org/readium/r2/shared/util/CoroutineQueue.kt

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,50 +6,74 @@
66

77
package org.readium.r2.shared.util
88

9-
import kotlin.coroutines.resume
10-
import kotlinx.coroutines.CancellableContinuation
119
import kotlinx.coroutines.CancellationException
10+
import kotlinx.coroutines.CompletableDeferred
11+
import kotlinx.coroutines.CoroutineDispatcher
1212
import kotlinx.coroutines.CoroutineScope
13-
import kotlinx.coroutines.MainScope
13+
import kotlinx.coroutines.Deferred
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.SupervisorJob
1416
import kotlinx.coroutines.cancel
1517
import kotlinx.coroutines.channels.Channel
1618
import kotlinx.coroutines.channels.trySendBlocking
1719
import kotlinx.coroutines.launch
18-
import kotlinx.coroutines.suspendCancellableCoroutine
20+
import kotlinx.coroutines.supervisorScope
1921
import org.readium.r2.shared.InternalReadiumApi
2022

2123
/**
22-
* Executes coroutines in a sequential order (FIFO).
24+
* CoroutineScope-like util to execute coroutines in a sequential order (FIFO).
25+
* As with a SupervisorJob, children can be cancelled or fail independently one from the other.
2326
*/
2427
@InternalReadiumApi
2528
public class CoroutineQueue(
26-
public val scope: CoroutineScope = MainScope()
29+
dispatcher: CoroutineDispatcher = Dispatchers.Main
2730
) {
31+
private val scope: CoroutineScope =
32+
CoroutineScope(dispatcher + SupervisorJob())
33+
2834
init {
2935
scope.launch {
3036
for (task in tasks) {
31-
task()
37+
// Don't fail the root job if one task fails.
38+
supervisorScope {
39+
task()
40+
}
3241
}
3342
}
3443
}
3544

3645
/**
3746
* Launches a coroutine in the queue.
47+
*
48+
* Exceptions thrown by [block] will be ignored.
3849
*/
39-
public fun launch(task: suspend () -> Unit) {
40-
tasks.trySendBlocking(Task(task)).getOrThrow()
50+
public fun launch(block: suspend () -> Unit) {
51+
tasks.trySendBlocking(Task(block)).getOrThrow()
52+
}
53+
54+
/**
55+
* Creates a coroutine in the queue and returns its future result
56+
* as an implementation of Deferred.
57+
*
58+
* Exceptions thrown by [block] will be caught and represented in the resulting [Deferred].
59+
*/
60+
public fun <T> async(block: suspend () -> T): Deferred<T> {
61+
val deferred = CompletableDeferred<T>()
62+
val task = Task(block, deferred)
63+
tasks.trySendBlocking(task).getOrThrow()
64+
return deferred
4165
}
4266

4367
/**
4468
* Launches a coroutine in the queue, and waits for its result.
69+
*
70+
* Exceptions thrown by [block] will be rethrown.
4571
*/
46-
public suspend fun <T> await(task: suspend () -> T): T =
47-
suspendCancellableCoroutine { cont ->
48-
tasks.trySendBlocking(Task(task, cont)).getOrThrow()
49-
}
72+
public suspend fun <T> await(block: suspend () -> T): T =
73+
async(block).await()
5074

5175
/**
52-
* Cancels all the coroutines in the queue.
76+
* Cancels this coroutine queue, including all its children with an optional cancellation cause.
5377
*/
5478
public fun cancel(cause: CancellationException? = null) {
5579
scope.cancel(cause)
@@ -59,11 +83,15 @@ public class CoroutineQueue(
5983

6084
private class Task<T>(
6185
val task: suspend () -> T,
62-
val continuation: CancellableContinuation<T>? = null
86+
val deferred: CompletableDeferred<T>? = null
6387
) {
6488
suspend operator fun invoke() {
65-
val result = task()
66-
continuation?.resume(result)
89+
try {
90+
val result = task()
91+
deferred?.complete(result)
92+
} catch (e: Exception) {
93+
deferred?.completeExceptionally(e)
94+
}
6795
}
6896
}
6997
}

test-app/src/main/java/org/readium/r2/testapp/reader/MediaService.kt

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import android.content.Intent
1313
import android.content.ServiceConnection
1414
import android.os.Build
1515
import android.os.IBinder
16+
import androidx.core.app.NotificationManagerCompat
1617
import androidx.core.app.ServiceCompat
17-
import androidx.core.content.ContextCompat
1818
import androidx.media3.session.MediaSession
1919
import androidx.media3.session.MediaSessionService
2020
import kotlinx.coroutines.*
@@ -54,18 +54,21 @@ class MediaService : MediaSessionService() {
5454
sessionMutable.asStateFlow()
5555

5656
fun closeSession() {
57-
ServiceCompat.stopForeground(this@MediaService, ServiceCompat.STOP_FOREGROUND_REMOVE)
58-
session.value?.mediaSession?.release()
59-
session.value?.navigator?.close()
60-
session.value?.coroutineScope?.cancel()
61-
sessionMutable.value = null
57+
Timber.d("closeSession")
58+
session.value?.let { session ->
59+
session.mediaSession.release()
60+
session.coroutineScope.cancel()
61+
session.navigator.close()
62+
sessionMutable.value = null
63+
}
6264
}
6365

6466
@OptIn(FlowPreview::class)
6567
fun <N> openSession(
6668
navigator: N,
6769
bookId: Long
6870
) where N : AnyMediaNavigator, N : Media3Adapter {
71+
Timber.d("openSession")
6972
val activityIntent = createSessionActivityIntent()
7073
val mediaSession = MediaSession.Builder(applicationContext, navigator.asMedia3Player())
7174
.setSessionActivity(activityIntent)
@@ -107,6 +110,12 @@ class MediaService : MediaSessionService() {
107110

108111
return PendingIntent.getActivity(applicationContext, 0, intent, flags)
109112
}
113+
114+
fun stop() {
115+
closeSession()
116+
ServiceCompat.stopForeground(this@MediaService, ServiceCompat.STOP_FOREGROUND_REMOVE)
117+
this@MediaService.stopSelf()
118+
}
110119
}
111120

112121
private val binder by lazy {
@@ -135,9 +144,18 @@ class MediaService : MediaSessionService() {
135144
override fun onTaskRemoved(rootIntent: Intent) {
136145
super.onTaskRemoved(rootIntent)
137146
Timber.d("Task removed. Stopping session and service.")
138-
// Close the navigator to allow the service to be stopped.
147+
// Close the session to allow the service to be stopped.
139148
binder.closeSession()
140-
stopSelf()
149+
binder.stop()
150+
}
151+
152+
override fun onDestroy() {
153+
Timber.d("Destroying MediaService.")
154+
binder.closeSession()
155+
// Ensure one more time that all notifications are gone and,
156+
// hopefully, pending intents cancelled.
157+
NotificationManagerCompat.from(this).cancelAll()
158+
super.onDestroy()
141159
}
142160

143161
companion object {
@@ -146,7 +164,12 @@ class MediaService : MediaSessionService() {
146164

147165
fun start(application: Application) {
148166
val intent = intent(application)
149-
ContextCompat.startForegroundService(application, intent)
167+
application.startService(intent)
168+
}
169+
170+
fun stop(application: Application) {
171+
val intent = intent(application)
172+
application.stopService(intent)
150173
}
151174

152175
suspend fun bind(application: Application): Binder {
@@ -162,10 +185,14 @@ class MediaService : MediaSessionService() {
162185

163186
override fun onServiceDisconnected(name: ComponentName) {
164187
Timber.d("MediaService disconnected.")
165-
// Should not happen, do nothing.
166188
}
167189

168190
override fun onNullBinding(name: ComponentName) {
191+
if (mediaServiceBinder.isCompleted) {
192+
// This happens when the service has successfully connected and later
193+
// stopped and disconnected.
194+
return
195+
}
169196
val errorMessage = "Failed to bind to MediaService."
170197
Timber.e(errorMessage)
171198
val exception = IllegalStateException(errorMessage)
@@ -179,11 +206,6 @@ class MediaService : MediaSessionService() {
179206
return mediaServiceBinder.await()
180207
}
181208

182-
fun stop(application: Application) {
183-
val intent = intent(application)
184-
application.stopService(intent)
185-
}
186-
187209
private fun intent(application: Application) =
188210
Intent(SERVICE_INTERFACE)
189211
// MediaSessionService.onBind requires the intent to have a non-null action

0 commit comments

Comments
 (0)