Skip to content

Commit 4e2f66d

Browse files
authored
Add a TTS navigator with background playback (#333)
1 parent fd1d781 commit 4e2f66d

File tree

77 files changed

+6119
-1955
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+6119
-1955
lines changed

.github/workflows/checks.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Checks
22

33
on:
4+
workflow_dispatch:
45
push:
56
branches: [ main, develop ]
67
pull_request:

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ androidx-lifecycle = "2.5.1"
2323
androidx-lifecycle-extensions = "2.2.0"
2424
androidx-media = "1.6.0"
2525
androidx-media2 = "1.2.1"
26+
androidx-media3 = "1.0.0-rc01"
2627
androidx-navigation = "2.5.2"
2728
androidx-paging = "3.1.1"
2829
androidx-recyclerview = "1.2.1"
@@ -87,6 +88,9 @@ androidx-lifecycle-vmsavedstate = { group = "androidx.lifecycle", name = "lifecy
8788
androidx-media = { group = "androidx.media", name = "media", version.ref = "androidx-media" }
8889
androidx-media2-session = { group = "androidx.media2", name = "media2-session", version.ref = "androidx-media2" }
8990
androidx-media2-player = { group = "androidx.media2", name = "media2-player", version.ref = "androidx-media2" }
91+
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "androidx-media3" }
92+
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "androidx-media3" }
93+
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "androidx-media3" }
9094
androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "androidx-navigation" }
9195
androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "androidx-navigation" }
9296
androidx-paging = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "androidx-paging" }
@@ -138,6 +142,7 @@ coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]
138142
exoplayer = ["google-exoplayer-core", "google-exoplayer-ui", "google-exoplayer-mediasession", "google-exoplayer-workmanager", "google-exoplayer-extension-media2"]
139143
lifecycle = ["androidx-lifecycle-common", "androidx-lifecycle-extensions", "androidx-lifecycle-livedata", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodel", "androidx-lifecycle-vmsavedstate", "androidx-lifecycle-viewmodel-compose"]
140144
media2 = ["androidx-media2-session", "androidx-media2-player"]
145+
media3 = ["androidx-media3-session", "androidx-media3-common", "androidx-media3-exoplayer"]
141146
navigation = ["androidx-navigation-fragment", "androidx-navigation-ui"]
142147
room = ["androidx-room-runtime", "androidx-room-ktx"]
143148
test-frameworks = ["junit", "androidx-ext-junit", "androidx-expresso-core", "robolectric", "kotlin-junit", "assertj", "kotlinx-coroutines-test"]

readium/navigator/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ dependencies {
6767
implementation(libs.bundles.lifecycle)
6868
implementation(libs.androidx.recyclerview)
6969
implementation(libs.androidx.media)
70+
implementation(libs.bundles.media3)
7071
implementation(libs.androidx.viewpager2)
7172
implementation(libs.androidx.webkit)
7273
// Needed to avoid a crash with API 31, see https://stackoverflow.com/a/69152986/1474476
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import androidx.media3.common.MediaMetadata
10+
import androidx.media3.common.MediaMetadata.PICTURE_TYPE_FRONT_COVER
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Deferred
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.async
15+
import org.readium.r2.shared.publication.Publication
16+
17+
/**
18+
* Builds media metadata using the given title, author and cover,
19+
* and fall back on what is in the publication.
20+
*/
21+
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
22+
internal class DefaultMediaMetadataFactory(
23+
private val publication: Publication,
24+
title: String? = null,
25+
author: String? = null,
26+
cover: ByteArray? = null
27+
) : MediaMetadataFactory {
28+
29+
private val coroutineScope =
30+
CoroutineScope(Dispatchers.Default)
31+
32+
private val title: String =
33+
title ?: publication.metadata.title
34+
35+
private val authors: String? =
36+
author ?: publication.metadata.authors
37+
.firstOrNull { it.name.isNotBlank() }?.name
38+
39+
private val cover: Deferred<ByteArray?> = coroutineScope.async {
40+
cover ?: publication.linkWithRel("cover")
41+
?.let { publication.get(it) }
42+
?.read()
43+
?.getOrNull()
44+
}
45+
46+
override suspend fun publicationMetadata(): MediaMetadata {
47+
val builder = MediaMetadata.Builder()
48+
.setTitle(title)
49+
.setTotalTrackCount(publication.readingOrder.size)
50+
51+
authors
52+
?.let { builder.setArtist(it) }
53+
54+
cover.await()
55+
?.let { builder.maybeSetArtworkData(it, PICTURE_TYPE_FRONT_COVER) }
56+
57+
return builder.build()
58+
}
59+
60+
override suspend fun resourceMetadata(index: Int): MediaMetadata {
61+
val builder = MediaMetadata.Builder()
62+
.setTrackNumber(index)
63+
.setTitle(title)
64+
65+
authors
66+
?.let { builder.setArtist(it) }
67+
68+
cover.await()
69+
?.let { builder.maybeSetArtworkData(it, PICTURE_TYPE_FRONT_COVER) }
70+
71+
return builder.build()
72+
}
73+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import org.readium.r2.shared.publication.Publication
10+
11+
/**
12+
* Builds a [MediaMetadataFactory] which will use the given title, author and cover,
13+
* and fall back on what is in the publication.
14+
*/
15+
class DefaultMediaMetadataProvider(
16+
private val title: String? = null,
17+
private val author: String? = null,
18+
private val cover: ByteArray? = null
19+
) : MediaMetadataProvider {
20+
21+
override fun createMetadataFactory(publication: Publication): MediaMetadataFactory {
22+
return DefaultMediaMetadataFactory(publication, title, author, cover)
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import androidx.media3.common.MediaMetadata
10+
11+
/**
12+
* Factory for the [MediaMetadata] associated with the publication and its resources.
13+
*
14+
* The metadata are used for example in the media-style Android notification.
15+
*/
16+
interface MediaMetadataFactory {
17+
18+
/**
19+
* Creates the [MediaMetadata] for the whole publication.
20+
*/
21+
suspend fun publicationMetadata(): MediaMetadata
22+
23+
/**
24+
* Creates the [MediaMetadata] for the reading order resource at the given [index].
25+
*/
26+
suspend fun resourceMetadata(index: Int): MediaMetadata
27+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import org.readium.r2.shared.publication.Publication
10+
11+
/**
12+
* To be implemented to use a custom [MediaMetadataFactory].
13+
*/
14+
fun interface MediaMetadataProvider {
15+
16+
fun createMetadataFactory(publication: Publication): MediaMetadataFactory
17+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import androidx.media3.common.Player
10+
import kotlinx.coroutines.flow.StateFlow
11+
import org.readium.r2.navigator.Navigator
12+
import org.readium.r2.shared.ExperimentalReadiumApi
13+
import org.readium.r2.shared.util.Closeable
14+
15+
@ExperimentalReadiumApi
16+
interface MediaNavigator<P : MediaNavigator.Position> : Navigator, Closeable {
17+
18+
/**
19+
* Marker interface for the [position] flow.
20+
*/
21+
interface Position
22+
23+
/**
24+
* State of the player.
25+
*/
26+
sealed interface State {
27+
28+
/**
29+
* The navigator is ready to play.
30+
*/
31+
interface Ready : State
32+
33+
/**
34+
* The end of the media has been reached.
35+
*/
36+
interface Ended : State
37+
38+
/**
39+
* The navigator cannot play because the buffer is starved.
40+
*/
41+
interface Buffering : State
42+
43+
/**
44+
* The navigator cannot play because an error occurred.
45+
*/
46+
interface Error : State
47+
}
48+
49+
/**
50+
* State of the playback.
51+
*
52+
* @param state The current state.
53+
* @param playWhenReady If the navigator should play as soon as the state is Ready.
54+
*/
55+
data class Playback(
56+
val state: State,
57+
val playWhenReady: Boolean
58+
)
59+
60+
/**
61+
* Indicates the current state of the playback.
62+
*/
63+
val playback: StateFlow<Playback>
64+
65+
val position: StateFlow<P>
66+
67+
/**
68+
* Resumes the playback at the current location.
69+
*/
70+
fun play()
71+
72+
/**
73+
* Pauses the playback.
74+
*/
75+
fun pause()
76+
77+
/**
78+
* Adapts this navigator to the media3 [Player] interface.
79+
*/
80+
fun asPlayer(): Player
81+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.api
8+
9+
import kotlinx.coroutines.flow.StateFlow
10+
import org.readium.r2.shared.ExperimentalReadiumApi
11+
import org.readium.r2.shared.publication.Locator
12+
13+
/**
14+
* A [MediaNavigator] aware of the utterances that are being read aloud.
15+
*/
16+
@ExperimentalReadiumApi
17+
interface SynchronizedMediaNavigator<P : MediaNavigator.Position> :
18+
MediaNavigator<P> {
19+
20+
interface Utterance<P : MediaNavigator.Position> {
21+
val text: String
22+
23+
val position: P
24+
25+
val range: IntRange?
26+
27+
val utteranceLocator: Locator
28+
29+
val tokenLocator: Locator?
30+
}
31+
32+
val utterance: StateFlow<Utterance<P>>
33+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2022 Readium Foundation. All rights reserved.
3+
* Use of this source code is governed by the BSD-style license
4+
* available in the top-level LICENSE file of the project.
5+
*/
6+
7+
package org.readium.r2.navigator.media3.audio
8+
9+
import androidx.media3.common.Player
10+
import kotlin.time.Duration
11+
import kotlinx.coroutines.flow.StateFlow
12+
import org.readium.r2.navigator.media3.api.MediaNavigator
13+
import org.readium.r2.navigator.preferences.Configurable
14+
import org.readium.r2.shared.ExperimentalReadiumApi
15+
16+
@ExperimentalReadiumApi
17+
interface AudioEngine<S : Configurable.Settings, P : Configurable.Preferences<P>, E : AudioEngine.Error> :
18+
Configurable<S, P> {
19+
20+
interface Error
21+
22+
data class Playback<E : Error>(
23+
val state: MediaNavigator.State,
24+
val playWhenReady: Boolean,
25+
val error: E?
26+
)
27+
28+
data class Position(
29+
val index: Int,
30+
val duration: Duration
31+
)
32+
33+
val playback: StateFlow<Playback<E>>
34+
35+
val position: StateFlow<Position>
36+
37+
fun play()
38+
39+
fun pause()
40+
41+
fun seek(index: Long, position: Duration)
42+
43+
fun close()
44+
45+
fun asPlayer(): Player
46+
}

0 commit comments

Comments
 (0)