Skip to content

Commit ac34d98

Browse files
authored
Implement a new audiobook navigator based on Jetpack Media2 (#80)
This new audiobook navigator departs from the previous one in the following way: - No `MediaController` is used. - No `Service` provided and no UI-related stuff (including for the notification zone). - Apps must build a `MediaControllerSession`, use a helper to get a session from it, and connect that session to their media service.
1 parent 12be7fa commit ac34d98

Some content is hidden

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

55 files changed

+2888
-927
lines changed

CHANGELOG.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
All notable changes to this project will be documented in this file. Take a look at [the migration guide](docs/migration-guide.md) to upgrade between two major versions.
44

5-
**Warning:** Features marked as *alpha* may change or be removed in a future release without notice. Use with caution.
5+
**Warning:** Features marked as *experimental* may change or be removed in a future release without notice. Use with caution.
66

77
## [Unreleased]
88

@@ -32,6 +32,10 @@ All notable changes to this project will be documented in this file. Take a look
3232
* The new `Navigator.Listener.onJumpToLocator()` API is called every time the navigator jumps to an explicit location, which might break the linear reading progression.
3333
* For example, it is called when clicking on internal links or programmatically calling `Navigator.go()`, but not when turning pages.
3434
* You can use this callback to implement a navigation history by differentiating between continuous and discontinuous moves.
35+
* (*experimental*) A new audiobook navigator based on Jetpack `media2`.
36+
* See the [pull request #80](https://github.com/readium/kotlin-toolkit/pull/80) for the differences with the previous audiobook navigator.
37+
* This navigator is located in its own module `readium-navigator-media2`. You will need to add it to your dependencies to use it.
38+
* The Test App demonstrates how to use the new audiobook navigator, see `MediaService` and `AudioReaderFragment`.
3539

3640
### Deprecated
3741

@@ -86,7 +90,7 @@ All notable changes to this project will be documented in this file. Take a look
8690

8791
#### Shared
8892

89-
* (*alpha*) A new Publication `SearchService` to search through the resources' content, with a default implementation `StringSearchService`.
93+
* (*experimental*) A new Publication `SearchService` to search through the resources' content, with a default implementation `StringSearchService`.
9094
* `ContentProtection.Scheme` can be used to identify protection technologies using unique URI identifiers.
9195
* `Link` objects from archive-based publication assets (e.g. an EPUB/ZIP) have additional properties for entry metadata.
9296
```json
@@ -108,12 +112,12 @@ All notable changes to this project will be documented in this file. Take a look
108112
109113
* The EPUB navigator is now able to navigate to a `Locator` using its `text` context. This is useful for search results or highlights missing precise locations.
110114
* Get or clear the current user selection of the navigators implementing `SelectableNavigator`.
111-
* (*alpha*) Support for the [Decorator API](https://github.com/readium/architecture/pull/160) to draw user interface elements over a publication's content.
115+
* (*experimental*) Support for the [Decorator API](https://github.com/readium/architecture/pull/160) to draw user interface elements over a publication's content.
112116
* This can be used to render highlights over a text selection, for example.
113117
* For now, only the EPUB navigator implements `DecorableNavigator`, for reflowable publications. You can implement custom decoration styles with `HtmlDecorationTemplate`.
114118
* Customize the EPUB selection context menu by providing a custom `ActionMode.Callback` implementation with `EpubNavigatorFragment.Configuration.selectionActionModeCallback`.
115119
* This is an alternative to overriding `Activity.onActionModeStarted()` which does not seem to work anymore with Android 12.
116-
* (*alpha*) A new audiobook navigator based on Android's [`MediaSession`](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session).
120+
* (*experimental*) A new audiobook navigator based on Android's [`MediaSession`](https://developer.android.com/guide/topics/media-apps/working-with-a-media-session).
117121
* It supports out-of-the-box media style notifications and background playback.
118122
* ExoPlayer is used by default for the actual playback, but you can use a custom player by implementing `MediaPlayer`.
119123
@@ -327,7 +331,7 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress
327331
#### Navigator
328332

329333
* Support for the new `Publication` model using the [Content Protection](https://readium.org/architecture/proposals/006-content-protection) for DRM rights and the [Fetcher](https://readium.org/architecture/proposals/002-composite-fetcher-api) for resource access.
330-
* (*alpha*) New `Fragment` implementations as an alternative to the legacy `Activity` ones (contributed by [@johanpoirier](https://github.com/readium/r2-navigator-kotlin/pull/148)).
334+
* (*experimental*) New `Fragment` implementations as an alternative to the legacy `Activity` ones (contributed by [@johanpoirier](https://github.com/readium/r2-navigator-kotlin/pull/148)).
331335
* The fragments are chromeless, to let you customize the reading UX.
332336
* To create the fragments use the matching factory such as `EpubNavigatorFragment.createFactory()`, as showcased in `R2EpubActivity`.
333337
* At the moment, highlights and TTS are not yet supported in the new EPUB navigator `Fragment`.
@@ -424,13 +428,13 @@ progression. Now if no reading progression is set, the `effectiveReadingProgress
424428
* Get the visible position from the current `Locator` with `locations.position`.
425429
* The total number of positions can be retrieved with `publication.positions().size`. It is a suspending function because computing positions the first time can be expensive.
426430
* `ReadiumWebPubParser` to parse all Readium Web Publication profiles, including [Audiobooks](https://readium.org/webpub-manifest/extensions/audiobook.html), [LCP for Audiobooks](https://readium.org/lcp-specs/notes/lcp-for-audiobooks.html) and [LCP for PDF](https://readium.org/lcp-specs/notes/lcp-for-pdf.html). It parses both manifests and packages.
427-
* (*alpha*) `PDFParser` to parse single PDF documents.
431+
* (*experimental*) `PDFParser` to parse single PDF documents.
428432
* The PDF parser is based on [PdfiumAndroid](https://github.com/barteksc/PdfiumAndroid/), which may increase the size of your apps. Please open an issue if this is a problem for you, as we are considering different solutions to fix this in a future release.
429433
430434
#### Navigator
431435
432436
* The [position](https://github.com/readium/architecture/tree/master/models/locators/positions) is now reported in the locators for EPUB, CBZ and PDF.
433-
* (*alpha*) [PDF navigator](https://github.com/readium/r2-navigator-kotlin/pull/130).
437+
* (*experimental*) [PDF navigator](https://github.com/readium/r2-navigator-kotlin/pull/130).
434438
* Supports both single PDF and LCP protected PDF.
435439
* As a proof of concept, [it is implemented using `Fragment` instead of `Activity`](https://github.com/readium/r2-navigator-kotlin/issues/115). `R2PdfActivity` showcases how to use the `PdfNavigatorFragment` with the new `NavigatorFragmentFactory`.
436440
* The navigator is based on [AndroidPdfViewer](https://github.com/barteksc/AndroidPdfViewer), which may increase the size of your apps. Please open an issue if this is a problem for you, as we are considering different solutions to fix this in a future release.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
plugins {
8+
id("com.android.library")
9+
id("kotlin-android")
10+
id("kotlin-parcelize")
11+
id("maven-publish")
12+
id("org.jetbrains.dokka")
13+
}
14+
15+
android {
16+
resourcePrefix = "readium_"
17+
18+
compileSdk = 31
19+
20+
defaultConfig {
21+
minSdk = 21
22+
targetSdk = 31
23+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
24+
}
25+
compileOptions {
26+
sourceCompatibility = JavaVersion.VERSION_1_8
27+
targetCompatibility = JavaVersion.VERSION_1_8
28+
}
29+
kotlinOptions {
30+
jvmTarget = "1.8"
31+
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.RequiresOptIn"
32+
}
33+
buildTypes {
34+
getByName("release") {
35+
isMinifyEnabled = false
36+
proguardFiles(getDefaultProguardFile("proguard-android.txt"))
37+
}
38+
}
39+
buildFeatures {
40+
viewBinding = true
41+
}
42+
}
43+
44+
afterEvaluate {
45+
publishing {
46+
publications {
47+
create<MavenPublication>("release") {
48+
from(components.getByName("release"))
49+
groupId = "com.github.readium"
50+
artifactId = "readium-navigator-media2"
51+
artifact(tasks.findByName("sourcesJar"))
52+
artifact(tasks.findByName("javadocsJar"))
53+
}
54+
}
55+
}
56+
}
57+
58+
dependencies {
59+
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
60+
61+
api(project(":readium:shared"))
62+
api(project(":readium:navigator"))
63+
64+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
65+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2")
66+
67+
implementation("com.jakewharton.timber:timber:5.0.1")
68+
69+
implementation("androidx.media2:media2-session:1.2.0")
70+
implementation("androidx.media2:media2-player:1.2.0")
71+
72+
implementation("com.google.android.exoplayer:exoplayer-core:2.16.1")
73+
implementation("com.google.android.exoplayer:extension-media2:2.16.1")
74+
75+
testImplementation("junit:junit:4.13.2")
76+
77+
androidTestImplementation("androidx.test.ext:junit:1.1.3")
78+
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
79+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
package="org.readium.navigator.media2">
4+
5+
</manifest>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package org.readium.navigator.media2
2+
3+
import android.graphics.Bitmap
4+
import androidx.media2.common.MediaMetadata
5+
import kotlinx.coroutines.CoroutineScope
6+
import kotlinx.coroutines.Deferred
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.async
9+
import org.readium.r2.shared.publication.Publication
10+
import org.readium.r2.shared.publication.services.cover
11+
12+
@ExperimentalMedia2
13+
internal class DefaultMetadataFactory(private val publication: Publication): MediaMetadataFactory {
14+
15+
private val coroutineScope =
16+
CoroutineScope(Dispatchers.Default)
17+
18+
private val authors: String?
19+
get() = publication.metadata.authors
20+
.joinToString(", ") { it.name }.takeIf { it.isNotBlank() }
21+
22+
private val cover: Deferred<Bitmap?> = coroutineScope.async {
23+
publication.cover()
24+
}
25+
26+
override suspend fun publicationMetadata(): MediaMetadata {
27+
val builder = MediaMetadata.Builder()
28+
.putString(MediaMetadata.METADATA_KEY_TITLE, publication.metadata.title)
29+
.putString(MediaMetadata.METADATA_KEY_ALBUM, publication.metadata.title)
30+
.putLong(MediaMetadata.METADATA_KEY_NUM_TRACKS, publication.readingOrder.size.toLong())
31+
32+
authors
33+
?.let {
34+
builder.putString(MediaMetadata.METADATA_KEY_AUTHOR, it)
35+
builder.putString(MediaMetadata.METADATA_KEY_ARTIST, it)
36+
}
37+
38+
publication.metadata.duration
39+
?.let { builder.putLong(MediaMetadata.METADATA_KEY_DURATION, it.toLong() * 1000) }
40+
41+
cover.await()
42+
?.let { builder.putBitmap(MediaMetadata.METADATA_KEY_ART, it)}
43+
44+
return builder.build()
45+
}
46+
47+
override suspend fun resourceMetadata(index: Int): MediaMetadata {
48+
// See the implementation for how each metadata is used in media2:
49+
// https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:media2/media2-session/src/main/java/androidx/media2/session/MediaNotificationHandler.java;l=175?q=MediaNotificationHandler
50+
51+
val builder = MediaMetadata.Builder()
52+
val link = publication.readingOrder[index]
53+
builder.putLong(MediaMetadata.METADATA_KEY_TRACK_NUMBER, index.toLong())
54+
builder.putString(MediaMetadata.METADATA_KEY_MEDIA_URI, link.href)
55+
builder.putString(MediaMetadata.METADATA_KEY_TITLE, link.title)
56+
builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, publication.metadata.title)
57+
builder.putString(MediaMetadata.METADATA_KEY_ALBUM, publication.metadata.title)
58+
builder.putLong(MediaMetadata.METADATA_KEY_DURATION, (link.duration?.toLong() ?: 0) * 1000)
59+
60+
authors?.let {
61+
builder.putString(MediaMetadata.METADATA_KEY_ARTIST, it)
62+
builder.putString(MediaMetadata.METADATA_KEY_AUTHOR, it)
63+
}
64+
cover.await()?.let { builder.putBitmap(MediaMetadata.METADATA_KEY_ART, it)}
65+
return builder.build()
66+
}
67+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* Copyright 2020 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.navigator.media2
8+
9+
import android.net.Uri
10+
import com.google.android.exoplayer2.C.LENGTH_UNSET
11+
import com.google.android.exoplayer2.C.RESULT_END_OF_INPUT
12+
import com.google.android.exoplayer2.upstream.BaseDataSource
13+
import com.google.android.exoplayer2.upstream.DataSource
14+
import com.google.android.exoplayer2.upstream.DataSpec
15+
import com.google.android.exoplayer2.upstream.TransferListener
16+
import kotlinx.coroutines.runBlocking
17+
import org.readium.r2.shared.fetcher.Resource
18+
import org.readium.r2.shared.fetcher.buffered
19+
import org.readium.r2.shared.publication.Publication
20+
import java.io.IOException
21+
22+
sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(message, cause) {
23+
class NotOpened(message: String) : ExoPlayerDataSourceException(message, null)
24+
class NotFound(message: String) : ExoPlayerDataSourceException(message, null)
25+
class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException("Failed to read $readLength bytes of URI $uri at offset $offset.", cause)
26+
}
27+
28+
/**
29+
* An ExoPlayer's [DataSource] which retrieves resources from a [Publication].
30+
*/
31+
class ExoPlayerDataSource internal constructor(private val publication: Publication) : BaseDataSource(/* isNetwork = */ true) {
32+
33+
class Factory(private val publication: Publication, private val transferListener: TransferListener? = null) : DataSource.Factory {
34+
35+
override fun createDataSource(): DataSource =
36+
ExoPlayerDataSource(publication).apply {
37+
if (transferListener != null) {
38+
addTransferListener(transferListener)
39+
}
40+
}
41+
42+
}
43+
44+
private data class OpenedResource(
45+
val resource: Resource,
46+
val uri: Uri,
47+
var position: Long,
48+
)
49+
50+
private var openedResource: OpenedResource? = null
51+
52+
override fun open(dataSpec: DataSpec): Long {
53+
val link = publication.linkWithHref(dataSpec.uri.toString())
54+
?: throw ExoPlayerDataSourceException.NotFound("Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest.")
55+
56+
val resource = publication.get(link)
57+
// Significantly improves performances, in particular with deflated ZIP entries.
58+
.buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
59+
60+
openedResource = OpenedResource(
61+
resource = resource,
62+
uri = dataSpec.uri,
63+
position = dataSpec.position,
64+
)
65+
66+
val bytesToRead =
67+
if (dataSpec.length != LENGTH_UNSET.toLong()) {
68+
dataSpec.length
69+
} else {
70+
val contentLength = contentLengthOf(dataSpec.uri, resource)
71+
?: return dataSpec.length
72+
contentLength - dataSpec.position
73+
}
74+
75+
return bytesToRead
76+
}
77+
78+
/** Cached content lengths indexed by their URL. */
79+
private var cachedLengths: MutableMap<String, Long> = mutableMapOf()
80+
81+
private fun contentLengthOf(uri: Uri, resource: Resource): Long? {
82+
cachedLengths[uri.toString()]?.let { return it }
83+
84+
val length = runBlocking { resource.length() }.getOrNull()
85+
?: return null
86+
87+
cachedLengths[uri.toString()] = length
88+
return length
89+
}
90+
91+
override fun read(target: ByteArray, offset: Int, length: Int): Int {
92+
if (length <= 0) {
93+
return 0
94+
}
95+
96+
val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened("No opened resource to read from. Did you call open()?")
97+
98+
try {
99+
val data = runBlocking {
100+
openedResource.resource
101+
.read(range = openedResource.position until (openedResource.position + length))
102+
.getOrThrow()
103+
}
104+
105+
if (data.isEmpty()) {
106+
return RESULT_END_OF_INPUT
107+
}
108+
109+
data.copyInto(
110+
destination = target,
111+
destinationOffset = offset,
112+
startIndex = 0,
113+
endIndex = data.size
114+
)
115+
116+
openedResource.position += data.count()
117+
return data.count()
118+
119+
} catch (e: Exception) {
120+
if (e is InterruptedException) {
121+
return 0
122+
}
123+
throw ExoPlayerDataSourceException.ReadFailed(
124+
uri = openedResource.uri,
125+
offset = offset,
126+
readLength = length,
127+
cause = e
128+
)
129+
}
130+
}
131+
132+
override fun getUri(): Uri? = openedResource?.uri
133+
134+
override fun close() {
135+
openedResource?.run {
136+
try {
137+
runBlocking { resource.close() }
138+
} catch (e: Exception) {
139+
if (e !is InterruptedException) {
140+
throw e
141+
}
142+
}
143+
}
144+
openedResource = null
145+
}
146+
147+
}

0 commit comments

Comments
 (0)