diff --git a/.ci-local/check-libraries.txt b/.ci-local/check-libraries.txt
index 458b2f2a8..866cab54b 100644
--- a/.ci-local/check-libraries.txt
+++ b/.ci-local/check-libraries.txt
@@ -100,7 +100,7 @@ edu.umn.minitex.pdf:edu.umn.minitex.pdf.pdfviewer
org.librarysimplified.audiobook.audioengine:org.librarysimplified.audiobook.audioengine.core
org.librarysimplified.audiobook.overdrive:org.librarysimplified.audiobook.overdrive.main
org.librarysimplified.audiobook:org.librarysimplified.audiobook.api
-org.librarysimplified.audiobook:org.librarysimplified.audiobook.downloads
+org.librarysimplified.audiobook:org.librarysimplified.audiobook.player.api
org.librarysimplified.audiobook:org.librarysimplified.audiobook.feedbooks
org.librarysimplified.audiobook:org.librarysimplified.audiobook.http
org.librarysimplified.audiobook:org.librarysimplified.audiobook.license_check.api
@@ -110,10 +110,8 @@ org.librarysimplified.audiobook:org.librarysimplified.audiobook.manifest_fulfill
org.librarysimplified.audiobook:org.librarysimplified.audiobook.manifest_fulfill.spi
org.librarysimplified.audiobook:org.librarysimplified.audiobook.manifest_parser.api
org.librarysimplified.audiobook:org.librarysimplified.audiobook.manifest_parser.webpub
-org.librarysimplified.audiobook:org.librarysimplified.audiobook.open_access
+org.librarysimplified.audiobook:org.librarysimplified.audiobook.exoplayer
org.librarysimplified.audiobook:org.librarysimplified.audiobook.parser.api
-org.librarysimplified.audiobook:org.librarysimplified.audiobook.rbdigital
-org.librarysimplified.audiobook:org.librarysimplified.audiobook.views
org.librarysimplified.drm.axis:org.librarysimplified.drm.axis.provider
org.librarysimplified.drm:org.librarysimplified.drm.core
org.librarysimplified.http:org.librarysimplified.http.api
diff --git a/build.gradle b/build.gradle
index e5b6c7017..3590cfe23 100644
--- a/build.gradle
+++ b/build.gradle
@@ -8,7 +8,7 @@ buildscript {
google()
}
- ext.kotlin_version = "1.5.30"
+ ext.kotlin_version = "1.6.10"
dependencies {
classpath 'com.android.tools.build:gradle:7.0.2'
@@ -32,6 +32,7 @@ ext {
android_compile_sdk_version = 31
android_min_sdk_version = 24
android_target_sdk_version = 31
+ compose_kotlin_compiler_extension_version = "1.1.1"
// Required for some dependencies only available from our private S3
//
diff --git a/build_aar.gradle b/build_aar.gradle
index 6f99c2012..522282f35 100644
--- a/build_aar.gradle
+++ b/build_aar.gradle
@@ -23,6 +23,9 @@ android {
kotlinOptions {
jvmTarget = "1.8"
}
+ composeOptions {
+ kotlinCompilerExtensionVersion compose_kotlin_compiler_extension_version
+ }
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
diff --git a/org.librarysimplified.android.platform b/org.librarysimplified.android.platform
index c704fc182..cbf24b03f 160000
--- a/org.librarysimplified.android.platform
+++ b/org.librarysimplified.android.platform
@@ -1 +1 @@
-Subproject commit c704fc182ac8a43a0b5c8503cc23fc1282981518
+Subproject commit cbf24b03f46f9cdbc18f24bf39271fe0753d8c82
diff --git a/simplified-app-openebooks/src/main/AndroidManifest.xml b/simplified-app-openebooks/src/main/AndroidManifest.xml
index 0fddef3a2..9844d4048 100644
--- a/simplified-app-openebooks/src/main/AndroidManifest.xml
+++ b/simplified-app-openebooks/src/main/AndroidManifest.xml
@@ -79,6 +79,17 @@
android:label="@string/appName"
android:theme="@style/OEI_ActionBar"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
this.log.debug("[{}]: selecting audio engine", briefID)
-
- val engine =
- PlayerAudioEngines.findBestFor(
- PlayerAudioEngineRequest(
- manifest = manifestResult.result,
- filter = { true },
- downloadProvider = NullDownloadProvider(),
- userAgent = PlayerUserAgent("unused")
- )
- )
-
- if (engine == null) {
- throw UnsupportedOperationException(
- "No audio engine is available to process the given request"
- )
- }
-
- this.log.debug(
- "[{}]: selected audio engine: {} {}",
- briefID,
- engine.engineProvider.name(),
- engine.engineProvider.version()
- )
-
- when (val bookResult = engine.bookProvider.create(this.parameters.context)) {
- is PlayerResult.Success -> bookResult.result.wholeBookDownloadTask.delete()
- is PlayerResult.Failure -> throw bookResult.failure
- }
-
- this.log.debug("[{}]: deleted audio book data", briefID)
}
}
}
diff --git a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/NullDownloadProvider.kt b/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/NullDownloadProvider.kt
deleted file mode 100644
index 392f54fc1..000000000
--- a/simplified-books-database/src/main/java/org/nypl/simplified/books/book_database/NullDownloadProvider.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.nypl.simplified.books.book_database
-
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-import org.librarysimplified.audiobook.api.PlayerDownloadProviderType
-import org.librarysimplified.audiobook.api.PlayerDownloadRequest
-
-/**
- * A download provider that does nothing.
- */
-
-internal class NullDownloadProvider : PlayerDownloadProviderType {
- override fun download(request: PlayerDownloadRequest): ListenableFuture {
- return Futures.immediateFailedFuture(UnsupportedOperationException())
- }
-}
diff --git a/simplified-tests/build.gradle b/simplified-tests/build.gradle
index c1be2e68f..fef1c79d8 100644
--- a/simplified-tests/build.gradle
+++ b/simplified-tests/build.gradle
@@ -65,15 +65,13 @@ dependencies {
api project(":simplified-webview")
api libs.nypl.audiobook.api
- api libs.nypl.audiobook.downloads
+ api libs.nypl.audiobook.player.api
api libs.nypl.audiobook.feedbooks
api libs.nypl.audiobook.manifest.license.check.api
api libs.nypl.audiobook.manifest.fulfill.api
api libs.nypl.audiobook.manifest.fulfill.basic
api libs.nypl.audiobook.manifest.parser.webpub
- api libs.nypl.audiobook.open.access
- api libs.nypl.audiobook.rbdigital
- api libs.nypl.audiobook.views
+ api libs.nypl.audiobook.exoplayer
implementation libs.io7m.jfunctional
implementation libs.io7m.jnull
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt
index 6e2fc15ae..9389bab8d 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/books/audio/AudioBookManifestStrategyTest.kt
@@ -20,7 +20,7 @@ import org.nypl.simplified.taskrecorder.api.TaskResult
import org.nypl.simplified.tests.MutableServiceDirectory
import org.nypl.simplified.tests.TestDirectories
import org.slf4j.LoggerFactory
-import rx.Observable
+import io.reactivex.Observable
import java.io.File
import java.net.URI
diff --git a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt
index cbdff8b2c..4c401ad51 100644
--- a/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt
+++ b/simplified-tests/src/test/java/org/nypl/simplified/tests/mocking/MockedAudioEngineProvider.kt
@@ -1,9 +1,9 @@
package org.nypl.simplified.tests.mocking
-import org.librarysimplified.audiobook.api.PlayerAudioBookProviderType
-import org.librarysimplified.audiobook.api.PlayerAudioEngineProviderType
-import org.librarysimplified.audiobook.api.PlayerAudioEngineRequest
+import org.librarysimplified.audiobook.player.api.PlayerAudioEngineProviderType
+import org.librarysimplified.audiobook.player.api.PlayerAudioEngineRequest
import org.librarysimplified.audiobook.api.PlayerVersion
+import org.librarysimplified.audiobook.player.api.PlayerFactoryType
import org.slf4j.LoggerFactory
/**
@@ -22,7 +22,7 @@ class MockedAudioEngineProvider : PlayerAudioEngineProviderType {
return "mocked"
}
- override fun tryRequest(request: PlayerAudioEngineRequest): PlayerAudioBookProviderType? {
+ override fun tryRequest(request: PlayerAudioEngineRequest): PlayerFactoryType? {
this.logger.debug("trying request: {}", request)
val next = onNextRequest
@@ -38,6 +38,6 @@ class MockedAudioEngineProvider : PlayerAudioEngineProviderType {
companion object {
- var onNextRequest: ((PlayerAudioEngineRequest) -> PlayerAudioBookProviderType?)? = null
+ var onNextRequest: ((PlayerAudioEngineRequest) -> PlayerFactoryType?)? = null
}
}
diff --git a/simplified-viewer-audiobook/build.gradle b/simplified-viewer-audiobook/build.gradle
index 2bb60f365..282ccfcbc 100644
--- a/simplified-viewer-audiobook/build.gradle
+++ b/simplified-viewer-audiobook/build.gradle
@@ -1,3 +1,9 @@
+android {
+ buildFeatures {
+ compose true
+ }
+}
+
dependencies {
implementation project(":simplified-accounts-api")
implementation project(":simplified-books-api")
@@ -9,25 +15,45 @@ dependencies {
implementation project(":simplified-networkconnectivity-api")
implementation project(":simplified-profiles-controller-api")
implementation project(":simplified-services-api")
- implementation project(":simplified-threads")
- implementation project(":simplified-ui-screen")
- implementation project(":simplified-ui-thread-api")
implementation project(":simplified-viewer-spi")
- api libs.nypl.audiobook.api
- api libs.nypl.audiobook.downloads
+ api libs.nypl.audiobook.player.api
api libs.nypl.audiobook.feedbooks
api libs.nypl.audiobook.manifest.license.check.api
api libs.nypl.audiobook.manifest.fulfill.api
api libs.nypl.audiobook.manifest.fulfill.basic
api libs.nypl.audiobook.manifest.parser.api
api libs.nypl.audiobook.manifest.parser.webpub
- api libs.nypl.audiobook.open.access
- api libs.nypl.audiobook.rbdigital
- api libs.nypl.audiobook.views
+ api libs.nypl.audiobook.json.web.token
+ api libs.nypl.audiobook.exoplayer
+
+ implementation libs.r2.shared
+ implementation libs.r2.streamer
+ implementation libs.r2.navigator.media2
implementation libs.androidx.activity
implementation libs.androidx.app.compat
+ implementation libs.androidx.lifecycle.ext
implementation libs.kotlin.stdlib
implementation libs.kotlin.reflect
+ implementation libs.kotlin.coroutines
+
+ implementation libs.androidx.compose.runtime
+ implementation libs.androidx.compose.material
+ implementation libs.androidx.compose.animation
+ implementation libs.androidx.compose.tooling
+ implementation libs.androidx.activity.compose
+ implementation libs.androidx.lifecycle.viewmodel.compose
+
+ testImplementation libs.okhttp3.mockwebserver
+ testImplementation libs.junit
+ testImplementation libs.junit.jupiter.api
+ testImplementation libs.junit.jupiter.engine
+
+ androidTestImplementation libs.junit
+ androidTestImplementation libs.androidx.test.espresso.core
+ androidTestImplementation libs.androidx.test.ext.junit.ktx
+ androidTestImplementation libs.androidx.test.rules
+ androidTestImplementation libs.androidx.test.runner
+ androidTestImplementation libs.androidx.fragment.testing
}
diff --git a/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerContract.kt b/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerContract.kt
new file mode 100644
index 000000000..e79d1cde1
--- /dev/null
+++ b/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerContract.kt
@@ -0,0 +1,625 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import org.joda.time.Duration
+import org.junit.Assert
+import org.junit.Test
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerCancelled
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerFinished
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerRunning
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerStopped
+import org.slf4j.Logger
+import java.util.concurrent.CountDownLatch
+
+/**
+ * Test contract for the {@link org.librarysimplified.audiobook.api.PlayerSleepTimerType} interface.
+ */
+
+abstract class PlayerSleepTimerContract {
+
+ abstract fun create(): PlayerSleepTimerType
+
+ abstract fun logger(): Logger
+
+ /**
+ * Opening a timer and then closing it works. Closing it multiple times isn't an issue.
+ */
+
+ @Test
+ fun testOpenClose() {
+ val timer = this.create()
+ Assert.assertFalse("Timer not closed", timer.isClosed)
+ timer.close()
+ Assert.assertTrue("Timer is closed", timer.isClosed)
+ timer.close()
+ Assert.assertTrue("Timer is closed", timer.isClosed)
+ }
+
+ /**
+ * Opening a timer, starting it, and letting it count down to completion works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCountdown() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running"
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+ Assert.assertNotNull(timer.isRunning)
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertEquals(7, events.size)
+ Assert.assertEquals("stopped", events[0])
+ Assert.assertEquals("running", events[1])
+ Assert.assertEquals("running", events[2])
+ Assert.assertEquals("running", events[3])
+ Assert.assertEquals("running", events[4])
+ Assert.assertEquals("finished", events[5])
+ Assert.assertEquals("stopped", events[6])
+ }
+
+ /**
+ * Opening a timer, starting it, and then cancelling it, works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCancel() {
+
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running"
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+ Assert.assertNotNull(timer.isRunning)
+
+ logger.debug("cancelling timer")
+ timer.cancel()
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+ Assert.assertNull(timer.isRunning)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertTrue("Must receive at least 4 events", events.size >= 4)
+ Assert.assertEquals("stopped", events.first())
+ Assert.assertTrue("Received at least a cancelled event", events.contains("cancelled"))
+ Assert.assertTrue("Received at least a running event", events.contains("running"))
+ Assert.assertEquals("stopped", events.last())
+ }
+
+ /**
+ * Opening a timer, starting it, and then cancelling it, works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCancelImmediate() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running"
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("cancelling timer")
+ timer.cancel()
+ Thread.sleep(250L)
+ Assert.assertNull(timer.isRunning)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertTrue("Must have received at least one events", events.size >= 1)
+ Assert.assertEquals("stopped", events.first())
+
+ /*
+ * This is timing sensitive. We may not receive a cancelled event if the timer doesn't even
+ * have time to start.
+ */
+
+ if (events.size >= 4) {
+ Assert.assertTrue("Received at least a running event", events.contains("running"))
+ }
+ if (events.size >= 3) {
+ Assert.assertTrue("Received at least a cancelled event", events.contains("cancelled"))
+ }
+
+ Assert.assertEquals("stopped", events.last())
+ }
+
+ /**
+ * Opening a timer, starting it, and then restarting it with a new time, works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testRestart() {
+ val events = ArrayList()
+
+ val logger = this.logger()
+ val timer = this.create()
+
+ timer.status.subscribe { event ->
+ logger.debug("event: {}", event)
+
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running " + event.remaining
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ }
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(4000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ logger.debug("restarting timer")
+ timer.start(Duration.millis(6000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+ Assert.assertNotNull(timer.isRunning)
+
+ logger.debug("closing timer")
+ timer.close()
+ Thread.sleep(1000L)
+
+ logger.debug("events: {}", events)
+ Assert.assertTrue("Must have received at least 4 events", events.size >= 4)
+ Assert.assertEquals("stopped", events.first())
+ Assert.assertTrue(events.contains("running PT4S"))
+ Assert.assertTrue(events.contains("running PT6S"))
+ Assert.assertEquals("stopped", events.last())
+ }
+
+ /**
+ * Running the timer to completion repeatedly, works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCompletionRepeated() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running " + event.remaining
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(1000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(2000L)
+
+ logger.debug("restarting timer")
+ timer.start(Duration.millis(1000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(2000L)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertEquals("Must have received 8 events", 8, events.size)
+ Assert.assertEquals("stopped", events[0])
+ Assert.assertEquals("running PT1S", events[1])
+ Assert.assertEquals("running PT0S", events[2])
+ Assert.assertEquals("finished", events[3])
+ Assert.assertEquals("running PT1S", events[4])
+ Assert.assertEquals("running PT0S", events[5])
+ Assert.assertEquals("finished", events[6])
+ Assert.assertEquals("stopped", events[7])
+ }
+
+ /**
+ * Explicit completion works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCompletionIndefinite() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running " + event.remaining
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(null)
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ logger.debug("finishing timer")
+ timer.finish()
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ logger.debug("finishing timer")
+ timer.finish()
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+ Assert.assertNull(timer.isRunning)
+
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertEquals("Must have received 4 events", 4, events.size)
+ Assert.assertEquals("stopped", events[0])
+ Assert.assertEquals("running null", events[1])
+ Assert.assertEquals("finished", events[2])
+ Assert.assertEquals("stopped", events[3])
+ }
+
+ /**
+ * Explicit completion works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testCompletionTimed() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running " + event.remaining
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.standardSeconds(2L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(500L)
+
+ logger.debug("finishing timer")
+ timer.finish()
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ Assert.assertEquals("Must have received 4 events", 4, events.size)
+ Assert.assertEquals("stopped", events[0])
+ Assert.assertEquals("running PT2S", events[1])
+ Assert.assertEquals("finished", events[2])
+ Assert.assertEquals("stopped", events[3])
+ }
+
+ /**
+ * Pausing a timer works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testPause() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running" + (if (event.paused) " paused" else "")
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ timer.pause()
+ Thread.sleep(1000L)
+ val running = timer.isRunning!!
+ Assert.assertTrue("Is paused", running.paused)
+
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ val distinctEvents = withoutSuccessiveDuplicates(events)
+ logger.debug("distinctEvents: {}", distinctEvents)
+
+ logger.debug("events: {}", events)
+ Assert.assertEquals(4, distinctEvents.size)
+ Assert.assertEquals("stopped", distinctEvents[0])
+ Assert.assertEquals("running", distinctEvents[1])
+ Assert.assertEquals("running paused", distinctEvents[2])
+ Assert.assertEquals("stopped", distinctEvents[3])
+ }
+
+ /**
+ * Pausing and unpausing a timer works.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testUnpause() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running" + (if (event.paused) " paused" else "")
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("waiting for timer")
+ timer.pause()
+
+ Thread.sleep(1000L)
+ val running = timer.isRunning!!
+ Assert.assertTrue("Is paused", running.paused)
+
+ Thread.sleep(1000L)
+
+ timer.unpause()
+ Thread.sleep(1000L)
+ val stillRunning = timer.isRunning!!
+ Assert.assertFalse("Is not paused", stillRunning.paused)
+
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ val distinctEvents = withoutSuccessiveDuplicates(events)
+ logger.debug("distinctEvents: {}", distinctEvents)
+
+ Assert.assertEquals(6, distinctEvents.size)
+ Assert.assertEquals("stopped", distinctEvents[0])
+ Assert.assertEquals("running", distinctEvents[1])
+ Assert.assertEquals("running paused", distinctEvents[2])
+ Assert.assertEquals("running", distinctEvents[3])
+ Assert.assertEquals("finished", distinctEvents[4])
+ Assert.assertEquals("stopped", distinctEvents[5])
+ }
+
+ /**
+ * Sending unpause requests to an unpaused timer is redundant.
+ */
+
+ @Test(timeout = 10_000L)
+ fun testUnpauseRedundant() {
+ val logger = this.logger()
+ val timer = this.create()
+
+ val waitLatch = CountDownLatch(1)
+ val events = ArrayList()
+
+ timer.status.subscribe(
+ { event ->
+ logger.debug("event: {}", event)
+ events.add(
+ when (event) {
+ PlayerSleepTimerStopped -> "stopped"
+ is PlayerSleepTimerRunning -> "running" + (if (event.paused) " paused" else "")
+ is PlayerSleepTimerCancelled -> "cancelled"
+ PlayerSleepTimerFinished -> "finished"
+ }
+ )
+ },
+ { waitLatch.countDown() },
+ { waitLatch.countDown() }
+ )
+
+ logger.debug("starting timer")
+ timer.start(Duration.millis(3000L))
+
+ logger.debug("waiting for timer")
+ Thread.sleep(1000L)
+
+ Thread.sleep(1000L)
+ val running = timer.isRunning!!
+ Assert.assertFalse("Is paused", running.paused)
+
+ timer.unpause()
+ Thread.sleep(1000L)
+ val stillRunning = timer.isRunning!!
+ Assert.assertFalse("Is not paused", stillRunning.paused)
+
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+ Thread.sleep(1000L)
+
+ logger.debug("closing timer")
+ timer.close()
+
+ waitLatch.await()
+
+ logger.debug("events: {}", events)
+ val distinctEvents = withoutSuccessiveDuplicates(events)
+ logger.debug("distinctEvents: {}", distinctEvents)
+
+ Assert.assertEquals(4, distinctEvents.size)
+ Assert.assertEquals("stopped", distinctEvents[0])
+ Assert.assertEquals("running", distinctEvents[1])
+ Assert.assertEquals("finished", distinctEvents[2])
+ Assert.assertEquals("stopped", distinctEvents[3])
+ }
+
+ private fun withoutSuccessiveDuplicates(values: List): List {
+ var current: T? = null
+ val results = ArrayList()
+ for (x in values) {
+ if (x != current) {
+ results.add(x)
+ current = x
+ }
+ }
+ return results
+ }
+}
diff --git a/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerTest.kt b/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerTest.kt
new file mode 100644
index 000000000..976b10b76
--- /dev/null
+++ b/simplified-viewer-audiobook/src/androidTest/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerTest.kt
@@ -0,0 +1,19 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import org.junit.runner.RunWith
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+@RunWith(AndroidJUnit4::class)
+@MediumTest
+class PlayerSleepTimerTest : PlayerSleepTimerContract() {
+ override fun create(): PlayerSleepTimerType {
+ return PlayerSleepTimer.create()
+ }
+
+ override fun logger(): Logger {
+ return LoggerFactory.getLogger(PlayerSleepTimerTest::class.java)
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookHelpers.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookHelpers.kt
deleted file mode 100644
index af18f1a1f..000000000
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookHelpers.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package org.nypl.simplified.viewer.audiobook
-
-import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled
-import org.nypl.simplified.books.api.BookID
-import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook
-import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
-import org.slf4j.LoggerFactory
-import java.io.IOException
-import java.net.URI
-
-internal object AudioBookHelpers {
-
- private val logger =
- LoggerFactory.getLogger(AudioBookHelpers::class.java)
-
- /**
- * Attempt to save a manifest in the books database.
- */
-
- fun saveManifest(
- profiles: ProfilesControllerType,
- bookId: BookID,
- manifestURI: URI,
- manifest: ManifestFulfilled
- ) {
- val handle =
- profiles.profileAccountForBook(bookId)
- .bookDatabase
- .entry(bookId)
- .findFormatHandle(BookDatabaseEntryFormatHandleAudioBook::class.java)
-
- val contentType = manifest.contentType
- if (handle == null) {
- this.logger.error(
- "Bug: Book database entry has no audio book format handle", IllegalStateException()
- )
- return
- }
-
- if (!handle.formatDefinition.supports(contentType)) {
- this.logger.error(
- "Server delivered an unsupported content type: {}: ", contentType, IOException()
- )
- return
- }
-
- handle.copyInManifestAndURI(manifest.data, manifestURI)
- }
-}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragment.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragment.kt
deleted file mode 100644
index 3a5287622..000000000
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragment.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package org.nypl.simplified.viewer.audiobook
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ProgressBar
-import androidx.fragment.app.Fragment
-import com.google.common.util.concurrent.ListeningExecutorService
-import org.librarysimplified.audiobook.manifest.api.PlayerManifest
-import org.librarysimplified.services.api.Services
-import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials
-import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
-import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
-import org.nypl.simplified.taskrecorder.api.TaskResult
-import org.nypl.simplified.ui.thread.api.UIThreadServiceType
-import org.slf4j.LoggerFactory
-import java.io.IOException
-
-/**
- * A fragment that downloads and updates an audio book manifest.
- */
-
-class AudioBookLoadingFragment : Fragment() {
-
- companion object {
-
- const val parametersKey =
- "org.nypl.simplified.viewer.audiobook.AudioBookLoadingFragment.parameters"
-
- /**
- * Create a new fragment.
- */
-
- fun newInstance(parameters: AudioBookLoadingFragmentParameters): AudioBookLoadingFragment {
- val args = Bundle()
- args.putSerializable(parametersKey, parameters)
- val fragment = AudioBookLoadingFragment()
- fragment.arguments = args
- return fragment
- }
- }
-
- private lateinit var ioExecutor: ListeningExecutorService
- private lateinit var listener: AudioBookLoadingFragmentListenerType
- private lateinit var playerParameters: AudioBookPlayerParameters
- private lateinit var profiles: ProfilesControllerType
- private lateinit var progress: ProgressBar
- private lateinit var strategies: AudioBookManifestStrategiesType
- private lateinit var uiThread: UIThreadServiceType
- private val log = LoggerFactory.getLogger(AudioBookLoadingFragment::class.java)
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- state: Bundle?
- ): View? {
- return inflater.inflate(R.layout.audio_book_player_loading, container, false)
- }
-
- override fun onViewCreated(view: View, state: Bundle?) {
- super.onViewCreated(view, state)
-
- this.progress = view.findViewById(R.id.audio_book_loading_progress)
- this.progress.isIndeterminate = true
- this.progress.max = 100
- }
-
- override fun onCreate(state: Bundle?) {
- this.log.debug("onCreate")
-
- super.onCreate(state)
-
- val services = Services.serviceDirectory()
-
- this.profiles =
- services.requireService(ProfilesControllerType::class.java)
- this.uiThread =
- services.requireService(UIThreadServiceType::class.java)
- this.strategies =
- services.requireService(AudioBookManifestStrategiesType::class.java)
- }
-
- override fun onActivityCreated(state: Bundle?) {
- super.onActivityCreated(state)
-
- this.listener = this.activity as AudioBookLoadingFragmentListenerType
-
- this.playerParameters =
- this.listener.onLoadingFragmentWantsAudioBookParameters()
- this.ioExecutor =
- this.listener.onLoadingFragmentWantsIOExecutor()
-
- val credentials =
- this.profiles.profileAccountForBook(this.playerParameters.bookID)
- .loginState
- .credentials
-
- this.ioExecutor.execute {
- try {
- this.uiThread.runOnUIThread {
- this.progress.isIndeterminate = true
- this.progress.progress = 0
- }
-
- val manifest = this.downloadAndSaveManifest(credentials)
-
- this.uiThread.runOnUIThread {
- this.progress.isIndeterminate = false
- this.progress.progress = 100
- }
-
- this.listener.onLoadingFragmentLoadingFinished(manifest)
- } catch (e: Exception) {
- this.uiThread.runOnUIThread {
- this.progress.isIndeterminate = false
- this.progress.progress = 100
- }
-
- this.listener.onLoadingFragmentLoadingFailed(e)
- }
- }
- }
-
- private fun downloadAndSaveManifest(
- credentials: AccountAuthenticationCredentials?
- ): PlayerManifest {
- val strategy =
- this.playerParameters.toManifestStrategy(
- this.strategies,
- this.listener::onLoadingFragmentIsNetworkConnectivityAvailable,
- credentials,
- this.requireContext().cacheDir
- )
- return when (val strategyResult = strategy.execute()) {
- is TaskResult.Success -> {
- AudioBookHelpers.saveManifest(
- profiles = this.profiles,
- bookId = this.playerParameters.bookID,
- manifestURI = this.playerParameters.manifestURI,
- manifest = strategyResult.result.fulfilled
- )
- strategyResult.result.manifest
- }
- is TaskResult.Failure ->
- throw IOException(strategyResult.message)
- }
- }
-}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentListenerType.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentListenerType.kt
deleted file mode 100644
index fe5b40754..000000000
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentListenerType.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package org.nypl.simplified.viewer.audiobook
-
-import com.google.common.util.concurrent.ListeningExecutorService
-import org.librarysimplified.audiobook.manifest.api.PlayerManifest
-
-/**
- * The interface that must be implemented by activities hosting a {@link AudioBookLoadingFragment}.
- */
-
-interface AudioBookLoadingFragmentListenerType {
-
- /**
- * @return A listening executor service for running background I/O operations
- */
-
- fun onLoadingFragmentWantsIOExecutor(): ListeningExecutorService
-
- /**
- * @return `true` if network connectivity is currently available
- */
-
- fun onLoadingFragmentIsNetworkConnectivityAvailable(): Boolean
-
- /**
- * @return The parameters that were used to instantiate the audio book player
- */
-
- fun onLoadingFragmentWantsAudioBookParameters(): AudioBookPlayerParameters
-
- /**
- * Called when the loading and parsing of the manifest has finished.
- */
-
- fun onLoadingFragmentLoadingFinished(manifest: PlayerManifest)
-
- /**
- * Called when the loading and parsing of the manifest has failed.
- */
-
- fun onLoadingFragmentLoadingFailed(exception: Exception)
-}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentParameters.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentParameters.kt
deleted file mode 100644
index e4f98398c..000000000
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookLoadingFragmentParameters.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.nypl.simplified.viewer.audiobook
-
-import java.io.Serializable
-
-/**
- * Parameters for the audio book player.
- */
-
-data class AudioBookLoadingFragmentParameters(
-
- /**
- * Reserved for future use.
- */
-
- val unused: Float? = null
-) : Serializable
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerActivity.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerActivity.kt
index d7ce4af11..cae7bd974 100644
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerActivity.kt
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerActivity.kt
@@ -1,744 +1,40 @@
package org.nypl.simplified.viewer.audiobook
-import android.app.Activity
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
-import android.widget.ImageView
-import androidx.appcompat.app.AlertDialog
+import androidx.activity.addCallback
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
-import com.google.common.util.concurrent.ListeningExecutorService
-import com.google.common.util.concurrent.MoreExecutors
-import io.reactivex.disposables.Disposable
-import org.librarysimplified.audiobook.api.PlayerAudioBookType
-import org.librarysimplified.audiobook.api.PlayerAudioEngineRequest
-import org.librarysimplified.audiobook.api.PlayerAudioEngines
-import org.librarysimplified.audiobook.api.PlayerDownloadProviderType
-import org.librarysimplified.audiobook.api.PlayerEvent
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventError
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventPlaybackRateChanged
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventChapterCompleted
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventChapterWaiting
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventPlaybackBuffering
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventPlaybackPaused
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventPlaybackProgressUpdate
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventPlaybackStarted
-import org.librarysimplified.audiobook.api.PlayerEvent.PlayerEventWithSpineElement.PlayerEventPlaybackStopped
-import org.librarysimplified.audiobook.api.PlayerPosition
-import org.librarysimplified.audiobook.api.PlayerResult
-import org.librarysimplified.audiobook.api.PlayerSleepTimer
-import org.librarysimplified.audiobook.api.PlayerSleepTimerType
-import org.librarysimplified.audiobook.api.PlayerSpineElementDownloadStatus.PlayerSpineElementDownloadExpired
-import org.librarysimplified.audiobook.api.PlayerType
-import org.librarysimplified.audiobook.api.PlayerUserAgent
-import org.librarysimplified.audiobook.api.extensions.PlayerExtensionType
-import org.librarysimplified.audiobook.downloads.DownloadProvider
-import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtension
-import org.librarysimplified.audiobook.manifest.api.PlayerManifest
-import org.librarysimplified.audiobook.views.PlayerAccessibilityEvent
-import org.librarysimplified.audiobook.views.PlayerFragment
-import org.librarysimplified.audiobook.views.PlayerFragmentListenerType
-import org.librarysimplified.audiobook.views.PlayerPlaybackRateFragment
-import org.librarysimplified.audiobook.views.PlayerSleepTimerFragment
-import org.librarysimplified.audiobook.views.PlayerTOCFragment
-import org.librarysimplified.http.api.LSHTTPClientType
-import org.librarysimplified.services.api.ServiceDirectoryType
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import org.librarysimplified.services.api.Services
-import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials
-import org.nypl.simplified.books.audio.AudioBookFeedbooksSecretServiceType
-import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
-import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook
-import org.nypl.simplified.books.controller.api.BooksControllerType
-import org.nypl.simplified.books.covers.BookCoverProviderType
-import org.nypl.simplified.feeds.api.FeedEntry
-import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType
-import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
-import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
-import org.nypl.simplified.taskrecorder.api.TaskResult
-import org.nypl.simplified.threads.NamedThreadPools
-import org.nypl.simplified.ui.screen.ScreenSizeInformationType
-import org.nypl.simplified.ui.thread.api.UIThreadServiceType
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import java.io.IOException
-import java.util.ServiceLoader
-import java.util.concurrent.Executors
-import java.util.concurrent.ScheduledExecutorService
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicBoolean
+import org.readium.navigator.media2.ExperimentalMedia2
/**
- * The main activity for playing audio books.
+ * The activity for playing audio books.
*/
-class AudioBookPlayerActivity :
- AppCompatActivity(),
- AudioBookLoadingFragmentListenerType,
- PlayerFragmentListenerType {
+@OptIn(ExperimentalMedia2::class)
+class AudioBookPlayerActivity : AppCompatActivity() {
- private val log: Logger = LoggerFactory.getLogger(AudioBookPlayerActivity::class.java)
-
- companion object {
-
- private const val PARAMETER_ID =
- "org.nypl.simplified.viewer.audiobook.AudioBookPlayerActivity.parameters"
-
- /**
- * Start a new player for the given book.
- *
- * @param from The parent activity
- * @param parameters The player parameters
- */
-
- fun startActivity(
- from: Activity,
- parameters: AudioBookPlayerParameters
- ) {
- val b = Bundle()
- b.putSerializable(this.PARAMETER_ID, parameters)
- val i = Intent(from, AudioBookPlayerActivity::class.java)
- i.putExtras(b)
- from.startActivity(i)
- }
- }
-
- @Volatile
- private var playerLastPosition: PlayerPosition? = null
- private lateinit var book: PlayerAudioBookType
- private lateinit var bookAuthor: String
- private lateinit var books: BooksControllerType
- private lateinit var bookSubscription: Disposable
- private lateinit var bookTitle: String
- private lateinit var covers: BookCoverProviderType
- private lateinit var downloadExecutor: ListeningExecutorService
- private lateinit var downloadProvider: PlayerDownloadProviderType
- private lateinit var formatHandle: BookDatabaseEntryFormatHandleAudioBook
- private lateinit var http: LSHTTPClientType
- private lateinit var networkConnectivity: NetworkConnectivityType
- private lateinit var parameters: AudioBookPlayerParameters
- private lateinit var player: PlayerType
- private lateinit var playerScheduledExecutor: ScheduledExecutorService
- private lateinit var playerSubscription: Disposable
- private lateinit var profiles: ProfilesControllerType
- private lateinit var screenSize: ScreenSizeInformationType
- private lateinit var sleepTimer: PlayerSleepTimerType
- private lateinit var strategies: AudioBookManifestStrategiesType
- private lateinit var uiThread: UIThreadServiceType
- private var playerInitialized: Boolean = false
- private val reloadingManifest = AtomicBoolean(false)
-
- private lateinit var fragmentFactory: AudiobookFragmentFactory
-
- @Volatile
- private var destroying: Boolean = false
+ private val parameters: AudioBookPlayerParameters by lazy { AudioBookPlayerContract.parseIntent(intent) }
+ private val services by lazy { Services.serviceDirectory() }
+ private val viewModelFactory = { AudioBookPlayerViewModel.Factory(application, parameters, services) }
+ private val viewModel: AudioBookPlayerViewModel by viewModels(factoryProducer = viewModelFactory)
override fun onCreate(savedInstanceState: Bundle?) {
- this.log.debug("onCreate")
- super.onCreate(null)
-
- val i = this.intent!!
- val a = i.extras!!
-
- this.parameters = a.getSerializable(PARAMETER_ID) as AudioBookPlayerParameters
-
- this.log.debug("manifest file: {}", this.parameters.manifestFile)
- this.log.debug("manifest uri: {}", this.parameters.manifestURI)
- this.log.debug("book id: {}", this.parameters.bookID)
- this.log.debug("entry id: {}", this.parameters.opdsEntry.id)
-
- this.setContentView(R.layout.audio_book_player_base)
-
- /*
- * Create a new downloader that is solely used to fetch audio book manifests.
- */
-
- this.downloadExecutor =
- MoreExecutors.listeningDecorator(
- NamedThreadPools.namedThreadPool(1, "audiobook-player", 19)
- )
- this.playerScheduledExecutor = Executors.newSingleThreadScheduledExecutor()
-
- /*
- * Create a sleep timer.
- */
- this.sleepTimer = PlayerSleepTimer.create()
-
- this.supportActionBar?.setDisplayHomeAsUpEnabled(false)
-
- this.bookTitle = this.parameters.opdsEntry.title
- this.bookAuthor = this.findBookAuthor(this.parameters.opdsEntry)
-
- val services = Services.serviceDirectory()
-
- this.profiles =
- services.requireService(ProfilesControllerType::class.java)
- this.http =
- services.requireService(LSHTTPClientType::class.java)
- this.uiThread =
- services.requireService(UIThreadServiceType::class.java)
- this.screenSize =
- services.requireService(ScreenSizeInformationType::class.java)
- this.books =
- services.requireService(BooksControllerType::class.java)
- this.covers =
- services.requireService(BookCoverProviderType::class.java)
- this.networkConnectivity =
- services.requireService(NetworkConnectivityType::class.java)
- this.strategies =
- services.requireService(AudioBookManifestStrategiesType::class.java)
-
- /*
- * Open the database format handle.
- */
-
- val formatHandleOpt =
- this.profiles.profileAccountForBook(this.parameters.bookID)
- .bookDatabase
- .entry(this.parameters.bookID)
- .findFormatHandle(BookDatabaseEntryFormatHandleAudioBook::class.java)
-
- if (formatHandleOpt == null) {
- val title =
- this.resources.getString(R.string.audio_book_player_error_book_open)
- this.showErrorWithRunnable(
- context = this,
- title = title,
- failure = IllegalStateException(title),
- execute = this::finish
- )
- return
- }
-
- this.formatHandle = formatHandleOpt
-
- this.downloadProvider =
- DownloadProvider.create(this.downloadExecutor)
-
- /*
- * Show a loading fragment.
- */
- this.supportFragmentManager.beginTransaction()
- .replace(R.id.audio_book_player_fragment_holder, AudioBookLoadingFragment::class.java, null, "LOADING")
- .commit()
-
- /*
- * Restore the activity title when the back stack is empty.
- */
-
- this.supportFragmentManager.addOnBackStackChangedListener {
- if (this.supportFragmentManager.backStackEntryCount == 0) {
- this.restoreActionBarTitle()
- }
- }
- }
-
- private fun findBookAuthor(entry: OPDSAcquisitionFeedEntry): String {
- if (entry.authors.isEmpty()) {
- return ""
- }
- return entry.authors.first()
- }
-
- override fun onPause() {
- this.log.debug("onPause")
- super.onPause()
- if (this.playerInitialized) {
- this.savePlayerPosition()
- }
- }
-
- override fun onDestroy() {
- this.log.debug("onDestroy")
- super.onDestroy()
-
- /*
- * We set a flag to indicate that the activity is currently being destroyed because
- * there may be scheduled tasks that try to execute after the activity has stopped. This
- * flag allows them to gracefully avoid running.
- */
-
- this.destroying = true
-
- /*
- * Cancel downloads, shut down the player, and close the book.
- */
-
- if (this.playerInitialized) {
- this.savePlayerPosition()
- this.cancelAllDownloads()
-
- try {
- this.player.close()
- } catch (e: Exception) {
- this.log.error("error closing player: ", e)
- }
-
- this.bookSubscription.dispose()
- this.playerSubscription.dispose()
-
- try {
- this.book.close()
- } catch (e: Exception) {
- this.log.error("error closing book: ", e)
- }
- }
-
- this.downloadExecutor.shutdown()
- this.playerScheduledExecutor.shutdown()
- }
-
- private fun savePlayerPosition() {
- val position = this.playerLastPosition
- if (position != null) {
- try {
- this.formatHandle.savePlayerPosition(position)
- } catch (e: Exception) {
- this.log.error("could not save player position: ", e)
- }
- }
- }
-
- override fun onLoadingFragmentWantsIOExecutor(): ListeningExecutorService {
- return this.downloadExecutor
- }
-
- override fun onLoadingFragmentIsNetworkConnectivityAvailable(): Boolean {
- return this.networkConnectivity.isNetworkAvailable
- }
-
- override fun onLoadingFragmentWantsAudioBookParameters(): AudioBookPlayerParameters {
- return this.parameters
- }
-
- override fun onLoadingFragmentLoadingFailed(exception: Exception) {
- this.showErrorWithRunnable(
- context = this,
- title = exception.message ?: "",
- failure = exception,
- execute = this::finish
- )
- }
-
- override fun onLoadingFragmentLoadingFinished(manifest: PlayerManifest) {
- this.log.debug("finished loading")
-
- /*
- * Ask the API for the best audio engine available that can handle the given manifest.
- */
-
- val engine = PlayerAudioEngines.findBestFor(
- PlayerAudioEngineRequest(
- manifest = manifest,
- filter = { true },
- downloadProvider = DownloadProvider.create(this.downloadExecutor),
- userAgent = PlayerUserAgent(this.parameters.userAgent)
- )
- )
-
- if (engine == null) {
- val title =
- this.resources.getString(R.string.audio_book_player_error_engine_open)
- this.showErrorWithRunnable(
- context = this,
- title = title,
- failure = IllegalStateException(title),
- execute = this::finish
- )
- return
- }
-
- this.log.debug(
- "selected audio engine: {} {}",
- engine.engineProvider.name(),
- engine.engineProvider.version()
- )
-
- /*
- * Load extensions.
- */
-
- val extensions =
- this.loadAndConfigureExtensions()
-
- /*
- * Create the audio book.
- */
-
- val bookResult =
- engine.bookProvider.create(
- context = this,
- extensions = extensions
- )
-
- if (bookResult is PlayerResult.Failure) {
- val title =
- this.resources.getString(R.string.audio_book_player_error_book_open)
- this.showErrorWithRunnable(
- context = this,
- title = title,
- failure = bookResult.failure,
- execute = this::finish
- )
- return
- }
-
- this.book = (bookResult as PlayerResult.Success).result
- this.player = this.book.createPlayer()
-
- this.bookSubscription =
- this.book.spineElementDownloadStatus.ofType(PlayerSpineElementDownloadExpired::class.java)
- .subscribe(this::onDownloadExpired)
- this.playerSubscription =
- this.player.events.subscribe(this::onPlayerEvent)
-
- this.playerInitialized = true
-
- this.restoreSavedPlayerPosition()
- this.startAllPartsDownloading()
-
- /*
- * Create and load the main player fragment into the holder view declared in the activity.
- */
+ super.onCreate(savedInstanceState)
- this.uiThread.runOnUIThread {
- // Sanity check; Verify the state of the lifecycle before continuing as it's possible the
- // activity could be finishing.
- fragmentFactory = AudiobookFragmentFactory(
- player, book, this, playerScheduledExecutor, sleepTimer
- )
- this.supportFragmentManager.fragmentFactory = fragmentFactory
- if (!this.isFinishing && !this.supportFragmentManager.isDestroyed) {
- this.supportFragmentManager
- .beginTransaction()
- .replace(
- R.id.audio_book_player_fragment_holder,
- PlayerFragment::class.java,
- null,
- "PLAYER"
- )
- .commitAllowingStateLoss()
+ onBackPressedDispatcher.addCallback(this) {
+ val shouldFinish = viewModel.onBackstackPressed()
+ if (shouldFinish) {
+ finish()
}
}
- }
-
- private fun downloadAndSaveManifest(
- credentials: AccountAuthenticationCredentials?
- ): PlayerManifest {
- this.log.debug("downloading and saving manifest")
- val strategy =
- this.parameters.toManifestStrategy(
- strategies = this.strategies,
- isNetworkAvailable = { this.networkConnectivity.isNetworkAvailable },
- credentials = credentials,
- cacheDirectory = this.cacheDir
- )
- return when (val strategyResult = strategy.execute()) {
- is TaskResult.Success -> {
- AudioBookHelpers.saveManifest(
- profiles = this.profiles,
- bookId = this.parameters.bookID,
- manifestURI = this.parameters.manifestURI,
- manifest = strategyResult.result.fulfilled
- )
- strategyResult.result.manifest
- }
- is TaskResult.Failure ->
- throw IOException(strategyResult.message)
- }
- }
-
- private fun onDownloadExpired(event: PlayerSpineElementDownloadExpired) {
- this.log.debug("onDownloadExpired: ", event.exception)
-
- if (this.reloadingManifest.compareAndSet(false, true)) {
- this.log.debug("attempting to download fresh manifest due to expired links")
- this.downloadExecutor.execute {
- try {
- this.book.replaceManifest(
- this.downloadAndSaveManifest(
- this.profiles.profileAccountForBook(this.parameters.bookID)
- .loginState
- .credentials
- )
- )
- } catch (e: Exception) {
- this.log.error("onDownloadExpired: failed to download/replace manifest: ", e)
- } finally {
- this.reloadingManifest.set(false)
- }
- }
- }
- }
-
- private fun loadAndConfigureExtensions(): List {
- val extensions =
- ServiceLoader.load(PlayerExtensionType::class.java)
- .toList()
-
- val services = Services.serviceDirectory()
- this.loadAndConfigureFeedbooks(services, extensions)
- return extensions
- }
-
- private fun loadAndConfigureFeedbooks(
- services: ServiceDirectoryType,
- extensions: List
- ) {
- val feedbooksConfigService =
- services.optionalService(AudioBookFeedbooksSecretServiceType::class.java)
-
- if (feedbooksConfigService != null) {
- this.log.debug("feedbooks configuration service is available; configuring extension")
- val extension =
- extensions.filterIsInstance()
- .firstOrNull()
- if (extension != null) {
- this.log.debug("feedbooks extension is available")
- extension.configuration = feedbooksConfigService.configuration
- } else {
- this.log.debug("feedbooks extension is not available")
- }
- }
- }
-
- private fun restoreSavedPlayerPosition() {
- var restored = false
-
- try {
- val position = this.formatHandle.format.position
- if (position != null) {
- this.player.movePlayheadToLocation(position)
- restored = true
- }
- } catch (e: Exception) {
- this.log.error("unable to load saved player position: ", e)
- }
-
- /*
- * Explicitly wind back to the start of the book if there isn't a suitable position saved.
- */
-
- if (!restored) {
- this.player.movePlayheadToLocation(this.book.spine[0].position)
- }
- }
-
- private fun startAllPartsDownloading() {
- if (this.networkConnectivity.isNetworkAvailable) {
- this.book.wholeBookDownloadTask.fetch()
- }
- }
-
- private fun cancelAllDownloads() {
- this.book.wholeBookDownloadTask.cancel()
- }
-
- private fun onPlayerEvent(event: PlayerEvent) {
- return when (event) {
- is PlayerEventPlaybackStarted ->
- this.playerLastPosition =
- event.spineElement.position.copy(offsetMilliseconds = event.offsetMilliseconds)
- is PlayerEventPlaybackBuffering ->
- this.playerLastPosition =
- event.spineElement.position.copy(offsetMilliseconds = event.offsetMilliseconds)
- is PlayerEventPlaybackProgressUpdate ->
- this.playerLastPosition =
- event.spineElement.position.copy(offsetMilliseconds = event.offsetMilliseconds)
- is PlayerEventPlaybackPaused ->
- this.playerLastPosition =
- event.spineElement.position.copy(offsetMilliseconds = event.offsetMilliseconds)
- is PlayerEventPlaybackStopped ->
- this.playerLastPosition =
- event.spineElement.position.copy(offsetMilliseconds = event.offsetMilliseconds)
-
- is PlayerEventChapterCompleted ->
- this.onPlayerChapterCompleted(event)
-
- is PlayerEventChapterWaiting -> Unit
- is PlayerEventPlaybackRateChanged -> Unit
- is PlayerEventError ->
- this.onLogPlayerError(event)
-
- PlayerEvent.PlayerEventManifestUpdated ->
- Unit
- }
- }
-
- private fun onPlayerChapterCompleted(event: PlayerEventChapterCompleted) {
- if (event.spineElement.next == null) {
- this.log.debug("book has finished")
-
- /*
- * Wait a few seconds before displaying the dialog asking if the user wants
- * to return the book.
- */
-
- this.playerScheduledExecutor.schedule(
- {
- if (!this.destroying) {
- this.uiThread.runOnUIThread { this.loanReturnShowDialog() }
- }
- },
- 5L, TimeUnit.SECONDS
- )
- }
- }
-
- private fun loanReturnShowDialog() {
- val alert = AlertDialog.Builder(this)
- alert.setTitle(R.string.audio_book_player_return_title)
- alert.setMessage(R.string.audio_book_player_return_question)
- alert.setNegativeButton(R.string.audio_book_player_do_keep) { dialog, _ ->
- dialog.dismiss()
- }
- alert.setPositiveButton(R.string.audio_book_player_do_return) { _, _ ->
- this.loanReturnPerform()
- this.finish()
- }
- alert.show()
- }
-
- private fun loanReturnPerform() {
- this.log.debug("returning loan")
-
- /*
- * We don't care if the return fails. The user can retry when they get back to their
- * book list, if necessary.
- */
-
- try {
- this.books.bookRevoke(this.parameters.accountID, this.parameters.bookID)
- } catch (e: Exception) {
- this.log.error("could not execute revocation: ", e)
- }
- }
-
- private fun onLogPlayerError(event: PlayerEventError) {
- val builder = StringBuilder(128)
- builder.append("Playback error:")
- builder.append('\n')
- builder.append(" Error Code: ")
- builder.append(event.errorCode)
- builder.append('\n')
- builder.append(" Spine Element: ")
- builder.append(event.spineElement)
- builder.append('\n')
- builder.append(" Offset: ")
- builder.append(event.offsetMilliseconds)
- builder.append('\n')
- builder.append(" Book Title: ")
- builder.append(this.parameters.opdsEntry.title)
- builder.append('\n')
- builder.append(" Book OPDS ID: ")
- builder.append(this.parameters.opdsEntry.id)
- builder.append('\n')
- builder.append(" Stacktrace:")
- builder.append('\n')
- this.log.error("{}", builder.toString(), event.exception)
- }
-
- override fun onPlayerPlaybackRateShouldOpen() {
- /*
- * The player fragment wants us to open the playback rate selection dialog.
- */
-
- this.uiThread.runOnUIThread {
- supportFragmentManager.beginTransaction()
- .add(PlayerPlaybackRateFragment::class.java, null, "PLAYER_RATE")
- .commit()
- }
- }
-
- override fun onPlayerSleepTimerShouldOpen() {
- /*
- * The player fragment wants us to open the sleep timer.
- */
-
- this.uiThread.runOnUIThread {
- supportFragmentManager.beginTransaction()
- .add(PlayerSleepTimerFragment::class.java, null, "PLAYER_SLEEP_TIMER")
- .commit()
- }
- }
-
- override fun onPlayerTOCShouldOpen() {
- /*
- * The player fragment wants us to open the table of contents. Load and display it, and
- * also set the action bar title.
- */
-
- this.uiThread.runOnUIThread {
- this.supportActionBar?.setTitle(R.string.audiobook_player_toc_title)
- this.supportFragmentManager
- .beginTransaction()
- .replace(
- R.id.audio_book_player_fragment_holder,
- PlayerTOCFragment::class.java,
- null,
- "PLAYER_TOC"
- )
- .addToBackStack(null)
- .commit()
- }
- }
-
- override fun onPlayerTOCWantsClose() {
- /*
- * The player fragment wants to close the table of contents dialog. Pop it from the back
- * stack and set the action bar title back to the original title.
- */
-
- this.supportFragmentManager.popBackStack()
- this.restoreActionBarTitle()
- }
-
- private fun restoreActionBarTitle() {
- this.supportActionBar?.setTitle(R.string.audio_book_player)
- }
-
- override fun onPlayerWantsAuthor(): String {
- return this.bookAuthor
- }
-
- override fun onPlayerWantsCoverImage(view: ImageView) {
- /*
- * Use the cover provider to load a cover image into the image view. The width and height
- * are essentially hints; the target image view almost certainly won't have a usable size
- * before this method is called, so we pass in a width/height hint that should give something
- * reasonably close to the expected 3:4 cover image size ratio.
- */
-
- this.covers.loadCoverInto(
- FeedEntry.FeedEntryOPDS(this.parameters.accountID, this.parameters.opdsEntry),
- view,
- this.screenSize.dpToPixels(300).toInt(),
- this.screenSize.dpToPixels(400).toInt()
- )
- }
-
- override fun onPlayerWantsTitle(): String {
- return this.parameters.opdsEntry.title
- }
-
- override fun onPlayerAccessibilityEvent(event: PlayerAccessibilityEvent) {
- }
-
- private fun showErrorWithRunnable(
- context: Context,
- title: String,
- failure: Exception,
- execute: () -> Unit
- ) {
- this.log.error("error: {}: ", title, failure)
- this.uiThread.runOnUIThread {
- AlertDialog.Builder(context)
- .setTitle(title)
- .setMessage(failure.localizedMessage)
- .setOnDismissListener {
- execute.invoke()
- }
- .show()
+ setContent {
+ val screen by viewModel.currentScreen.collectAsState()
+ screen.Screen()
}
}
}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerContract.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerContract.kt
new file mode 100644
index 000000000..2bae05827
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerContract.kt
@@ -0,0 +1,22 @@
+package org.nypl.simplified.viewer.audiobook
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.os.bundleOf
+
+object AudioBookPlayerContract {
+
+ private const val PARAMETER_ID =
+ "org.nypl.simplified.viewer.audiobook.AudioBookPlayerActivity.parameters"
+
+ fun createIntent(context: Context, parameters: AudioBookPlayerParameters): Intent {
+ val bundle = bundleOf(PARAMETER_ID to parameters)
+ val intent = Intent(context, AudioBookPlayerActivity::class.java)
+ intent.putExtras(bundle)
+ return intent
+ }
+
+ fun parseIntent(intent: Intent): AudioBookPlayerParameters =
+ intent.extras!!.getSerializable(PARAMETER_ID) as AudioBookPlayerParameters
+}
+
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerParameters.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerParameters.kt
index c773dccf0..5ae274080 100644
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerParameters.kt
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerParameters.kt
@@ -1,16 +1,7 @@
package org.nypl.simplified.viewer.audiobook
-import one.irradia.mime.vanilla.MIMEParser
-import org.librarysimplified.audiobook.api.PlayerUserAgent
-import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled
-import org.librarysimplified.services.api.Services
-import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials
import org.nypl.simplified.accounts.api.AccountID
import org.nypl.simplified.books.api.BookID
-import org.nypl.simplified.books.audio.AudioBookCredentials
-import org.nypl.simplified.books.audio.AudioBookManifestRequest
-import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
-import org.nypl.simplified.books.audio.AudioBookManifestStrategyType
import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
import java.io.File
import java.io.Serializable
@@ -63,59 +54,5 @@ data class AudioBookPlayerParameters(
*/
val opdsEntry: OPDSAcquisitionFeedEntry
-) : Serializable {
+) : Serializable
- /**
- * Create a manifest strategy for the current parameters.
- */
-
- fun toManifestStrategy(
- strategies: AudioBookManifestStrategiesType,
- isNetworkAvailable: () -> Boolean,
- credentials: AccountAuthenticationCredentials?,
- cacheDirectory: File
- ): AudioBookManifestStrategyType {
- val manifestContentType =
- MIMEParser.parseRaisingException(this.manifestContentType)
- val userAgent =
- PlayerUserAgent(this.userAgent)
-
- val audioBookCredentials =
- when (credentials) {
- is AccountAuthenticationCredentials.Basic -> {
- if (credentials.password.value.isBlank()) {
- AudioBookCredentials.UsernameOnly(
- userName = credentials.userName.value
- )
- } else {
- AudioBookCredentials.UsernamePassword(
- userName = credentials.userName.value,
- password = credentials.password.value
- )
- }
- }
- is AccountAuthenticationCredentials.OAuthWithIntermediary ->
- AudioBookCredentials.BearerToken(credentials.accessToken)
- is AccountAuthenticationCredentials.SAML2_0 ->
- AudioBookCredentials.BearerToken(credentials.accessToken)
- null ->
- null
- }
-
- val request =
- AudioBookManifestRequest(
- targetURI = this.manifestURI,
- contentType = manifestContentType,
- userAgent = userAgent,
- credentials = audioBookCredentials,
- services = Services.serviceDirectory(),
- isNetworkAvailable = isNetworkAvailable,
- loadFallbackData = {
- ManifestFulfilled(manifestContentType, this.manifestFile.readBytes())
- },
- cacheDirectory = cacheDirectory
- )
-
- return strategies.createStrategy(request)
- }
-}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerService.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerService.kt
new file mode 100644
index 000000000..cfa6a6b6b
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerService.kt
@@ -0,0 +1,130 @@
+package org.nypl.simplified.viewer.audiobook
+
+import android.content.Intent
+import android.os.IBinder
+import androidx.lifecycle.lifecycleScope
+import androidx.media2.session.MediaSession
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.sample
+import org.librarysimplified.audiobook.api.PlayerPosition
+import org.nypl.simplified.viewer.audiobook.session.LifecycleMediaSessionService
+import org.readium.navigator.media2.ExperimentalMedia2
+import org.readium.r2.shared.publication.Locator
+import org.readium.r2.shared.publication.indexOfFirstWithHref
+import org.slf4j.LoggerFactory
+import kotlin.time.ExperimentalTime
+
+@OptIn(ExperimentalTime::class, ExperimentalMedia2::class, ExperimentalCoroutinesApi::class)
+class AudioBookPlayerService : LifecycleMediaSessionService() {
+
+ private val logger =
+ LoggerFactory.getLogger(AudioBookPlayerService::class.java)
+
+ /**
+ * The service interface to be used by the app.
+ */
+ inner class Binder : android.os.Binder() {
+
+ private var saveLocationJob: Job? = null
+
+ internal var sessionHolder: AudioBookPlayerSessionHolder? = null
+ private set
+
+ fun closeSession() {
+ sessionHolder?.let { holder ->
+ savePlayerPosition(holder.navigator.currentLocator.value, holder)
+ }
+
+ stopForeground(true)
+ saveLocationJob?.cancel()
+ saveLocationJob = null
+ sessionHolder?.close()
+ sessionHolder = null
+ }
+
+ @OptIn(FlowPreview::class)
+ fun bindNavigator(
+ sessionHolder: AudioBookPlayerSessionHolder
+ ) {
+ addSession(sessionHolder.mediaSession)
+
+ saveLocationJob = sessionHolder.navigator.currentLocator
+ .sample(3000)
+ .onEach { locator -> savePlayerPosition(locator, sessionHolder) }
+ .launchIn(lifecycleScope)
+
+ this.sessionHolder = sessionHolder
+ }
+
+ private fun savePlayerPosition(
+ locator: Locator,
+ sessionHolder: AudioBookPlayerSessionHolder
+ ) {
+ val offset =
+ locator.locations.fragments.first()
+ .substringAfter("t=").toLong()
+
+ val chapter =
+ sessionHolder.navigator.publication.readingOrder.indexOfFirstWithHref(locator.href)!!
+
+ val position = PlayerPosition(
+ title = locator.title,
+ part = 0,
+ chapter = chapter ,
+ offsetMilliseconds = offset
+ )
+
+ sessionHolder.formatHandle.savePlayerPosition(position)
+ logger.debug("Position $position saved to the database.")
+ }
+ }
+
+ private val binder by lazy {
+ Binder()
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ logger.debug("AudioBookPlayerService created.")
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ logger.debug("onBind called with $intent")
+
+ return if (intent.action == SERVICE_INTERFACE) {
+ super.onBind(intent)
+ // Readium-aware client.
+ logger.debug("Returning custom binder.")
+ binder
+ } else {
+ // External controller.
+ logger.debug("Returning MediaSessionService binder.")
+ super.onBind(intent)
+ }
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return binder.sessionHolder?.mediaSession
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ logger.debug("MediaService destroyed.")
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent) {
+ super.onTaskRemoved(rootIntent)
+ logger.debug("Task removed. Stopping session and service.")
+ // Close the navigator to allow the service to be stopped.
+ binder.closeSession()
+ stopSelf()
+ }
+
+ companion object {
+ const val SERVICE_INTERFACE = "org.nypl.simplified.viewer.audiobook.service.AudioBookPlayerService"
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerSessionHolder.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerSessionHolder.kt
new file mode 100644
index 000000000..0f1c2b96e
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerSessionHolder.kt
@@ -0,0 +1,24 @@
+package org.nypl.simplified.viewer.audiobook
+
+import androidx.media2.session.MediaSession
+import org.librarysimplified.audiobook.player.api.PlayerType
+import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle
+import org.readium.navigator.media2.ExperimentalMedia2
+import org.readium.navigator.media2.MediaNavigator
+
+@OptIn(ExperimentalMedia2::class)
+data class AudioBookPlayerSessionHolder(
+ val parameters: AudioBookPlayerParameters,
+ val navigator: MediaNavigator,
+ val mediaSession: MediaSession,
+ val player: PlayerType,
+ val formatHandle: BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook
+) {
+
+ fun close() {
+ mediaSession.close()
+ navigator.close()
+ navigator.publication.close()
+ player.close()
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerViewModel.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerViewModel.kt
new file mode 100644
index 000000000..f70a641c9
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookPlayerViewModel.kt
@@ -0,0 +1,164 @@
+package org.nypl.simplified.viewer.audiobook
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.librarysimplified.services.api.ServiceDirectoryType
+import org.nypl.simplified.books.audio.AudioBookFeedbooksSecretServiceType
+import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
+import org.nypl.simplified.books.covers.BookCoverProviderType
+import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType
+import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
+import org.nypl.simplified.viewer.audiobook.ui.navigation.Listener
+import org.nypl.simplified.viewer.audiobook.ui.navigation.Screen
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreenState
+import org.nypl.simplified.viewer.audiobook.session.SessionBuilder
+import org.readium.navigator.media2.ExperimentalMedia2
+import org.readium.r2.shared.util.Try
+import org.slf4j.LoggerFactory
+
+@OptIn(ExperimentalMedia2::class, ExperimentalCoroutinesApi::class)
+internal class AudioBookPlayerViewModel private constructor(
+ private val application: Application,
+ private val parameters: AudioBookPlayerParameters,
+ private val sessionBuilder: SessionBuilder
+) : ViewModel() {
+
+ private val logger =
+ LoggerFactory.getLogger(AudioBookPlayerViewModel::class.java)
+
+ private val serviceBinder: CompletableDeferred =
+ CompletableDeferred()
+
+ init {
+ startAudioBookPlayerService()
+ tryStartSession()
+ }
+
+ private fun startAudioBookPlayerService() {
+ val mediaServiceConnection = object : ServiceConnection {
+
+ override fun onServiceConnected(name: ComponentName?, service: IBinder) {
+ logger.debug("MediaService bound.")
+ serviceBinder.complete(service as AudioBookPlayerService.Binder)
+ }
+
+ override fun onServiceDisconnected(name: ComponentName) {
+ logger.debug("MediaService disconnected.")
+ // Should not happen, do nothing.
+ }
+
+ override fun onNullBinding(name: ComponentName) {
+ logger.debug("Failed to bind to MediaService.")
+ // Should not happen, do nothing.
+ }
+ }
+
+ // AudioBookPlayerService.onBind requires the intent to have a non-null action.
+ val intent = Intent(AudioBookPlayerService.SERVICE_INTERFACE)
+ .apply { setClass(application, AudioBookPlayerService::class.java) }
+ application.startService(intent)
+ application.bindService(intent, mediaServiceConnection, 0)
+ }
+
+ private fun tryStartSession() {
+ viewModelScope.launch {
+ getSession().fold(
+ onSuccess = { session ->
+ listener.onPlayerReady(
+ PlayerScreenState(session.navigator, viewModelScope)
+ )
+ },
+ onFailure = {
+ listener.onLoadingException(it)
+ }
+ )
+ }
+ }
+
+ private suspend fun getSession(): Try {
+ val binder = serviceBinder.await()
+
+ binder.sessionHolder
+ ?.takeIf { (it.parameters.bookID == parameters.bookID) }
+ ?.let { return Try.success(it) }
+
+ binder.sessionHolder?.close()
+
+ val sessionHolderTry =
+ sessionBuilder.open(this.parameters)
+
+ sessionHolderTry.onSuccess { holder ->
+ holder.navigator.play()
+ serviceBinder.await().bindNavigator(holder)
+ }
+
+ return sessionHolderTry
+ }
+
+ private val listener: Listener =
+ Listener()
+
+ val currentScreen: StateFlow
+ get() = listener.currentScreen
+
+ fun onBackstackPressed(): Boolean {
+ val hasNavigated = listener.onBackstackPressed()
+ val shouldFinish = !hasNavigated
+ if (shouldFinish) {
+ onCloseActivity()
+ }
+ return shouldFinish
+ }
+
+ private fun onCloseActivity() {
+ if (serviceBinder.isCompleted)
+ serviceBinder.getCompleted().closeSession()
+ }
+
+ internal class Factory(
+ private val application: Application,
+ private val parameters: AudioBookPlayerParameters,
+ services: ServiceDirectoryType
+ ) : ViewModelProvider.NewInstanceFactory() {
+
+ private val strategies =
+ services.requireService(AudioBookManifestStrategiesType::class.java)
+ private val networkConnectivity =
+ services.requireService(NetworkConnectivityType::class.java)
+ private val profiles =
+ services.requireService(ProfilesControllerType::class.java)
+ private val covers =
+ services.requireService(BookCoverProviderType::class.java)
+ private val feedbooksConfigService =
+ services.optionalService(AudioBookFeedbooksSecretServiceType::class.java)
+
+ private val sessionBuilder : SessionBuilder =
+ SessionBuilder(
+ application,
+ profiles,
+ strategies,
+ networkConnectivity,
+ application.cacheDir,
+ feedbooksConfigService
+ )
+
+ override fun create(modelClass: Class): T {
+ return when {
+ modelClass.isAssignableFrom(AudioBookPlayerViewModel::class.java) ->
+ AudioBookPlayerViewModel(application, parameters, sessionBuilder) as T
+ else ->
+ super.create(modelClass)
+ }
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookViewer.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookViewer.kt
index f8db28ffa..3ac41a76a 100644
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookViewer.kt
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudioBookViewer.kt
@@ -68,6 +68,9 @@ class AudioBookViewer : ViewerProviderType {
userAgent = httpClient.userAgent()
)
- AudioBookPlayerActivity.startActivity(activity, params)
+ val intent =
+ AudioBookPlayerContract.createIntent(activity, params)
+
+ activity.startActivity(intent)
}
}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudiobookFragmentFactory.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudiobookFragmentFactory.kt
deleted file mode 100644
index ef64ee47f..000000000
--- a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/AudiobookFragmentFactory.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.nypl.simplified.viewer.audiobook
-
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentFactory
-import org.librarysimplified.audiobook.api.PlayerAudioBookType
-import org.librarysimplified.audiobook.api.PlayerSleepTimerType
-import org.librarysimplified.audiobook.api.PlayerType
-import org.librarysimplified.audiobook.views.PlayerFragment
-import org.librarysimplified.audiobook.views.PlayerFragmentListenerType
-import org.librarysimplified.audiobook.views.PlayerPlaybackRateFragment
-import org.librarysimplified.audiobook.views.PlayerSleepTimerFragment
-import org.librarysimplified.audiobook.views.PlayerTOCFragment
-import java.util.concurrent.ScheduledExecutorService
-
-class AudiobookFragmentFactory(
- private val player: PlayerType,
- private val book: PlayerAudioBookType,
- private val listener: PlayerFragmentListenerType,
- private val scheduledExecutorService: ScheduledExecutorService,
- private val sleepTimer: PlayerSleepTimerType
-) : FragmentFactory() {
- override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
- return when (className) {
- AudioBookLoadingFragment::class.java.name -> AudioBookLoadingFragment()
- PlayerFragment::class.java.name -> {
- PlayerFragment(listener, player, book, scheduledExecutorService, sleepTimer)
- }
- PlayerPlaybackRateFragment::class.java.name ->
- PlayerPlaybackRateFragment(listener, player)
- PlayerSleepTimerFragment::class.java.name ->
- PlayerSleepTimerFragment(listener, player, sleepTimer)
- PlayerTOCFragment::class.java.name ->
- PlayerTOCFragment(listener, book, player)
- else -> super.instantiate(classLoader, className)
- }
- }
-}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/BearerTokenHttpClientCallback.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/BearerTokenHttpClientCallback.kt
new file mode 100644
index 000000000..f9a3da29e
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/BearerTokenHttpClientCallback.kt
@@ -0,0 +1,29 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import org.nypl.simplified.books.audio.AudioBookCredentials
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.http.DefaultHttpClient
+import org.readium.r2.shared.util.http.HttpRequest
+import org.readium.r2.shared.util.http.HttpTry
+import org.slf4j.LoggerFactory
+
+internal class BearerTokenHttpClientCallback(
+ private val tokenFactory: (HttpRequest) -> AudioBookCredentials.BearerToken,
+) : DefaultHttpClient.Callback {
+
+ private val logger =
+ LoggerFactory.getLogger(BearerTokenHttpClientCallback::class.java)
+
+
+ override suspend fun onStartRequest(request: HttpRequest): HttpTry {
+ this.logger.debug("running bearer token authentication for {}", request.url)
+
+ val token = tokenFactory(request)
+
+ return Try.success(
+ request.buildUpon()
+ .setHeader("Authorization", token.accessToken)
+ .build()
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksHttpClient.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksHttpClient.kt
new file mode 100644
index 000000000..7352fa1d9
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksHttpClient.kt
@@ -0,0 +1,19 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtensionConfiguration
+import org.readium.r2.shared.util.http.DefaultHttpClient
+import org.readium.r2.shared.util.http.HttpClient
+
+internal class FeedbooksHttpClientFactory(
+ private val configuration: FeedbooksPlayerExtensionConfiguration,
+) {
+
+ fun createHttpClient(): HttpClient {
+
+ val tokenFactory = FeedbooksTokenFactory(configuration)::createToken
+
+ return DefaultHttpClient(
+ callback = BearerTokenHttpClientCallback(tokenFactory)
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksTokenFactory.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksTokenFactory.kt
new file mode 100644
index 000000000..e06e58fc4
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/FeedbooksTokenFactory.kt
@@ -0,0 +1,46 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtensionConfiguration
+import org.librarysimplified.audiobook.json_web_token.JOSEHeader
+import org.librarysimplified.audiobook.json_web_token.JSONWebSignature
+import org.librarysimplified.audiobook.json_web_token.JSONWebSignatureAlgorithmHMACSha256
+import org.librarysimplified.audiobook.json_web_token.JSONWebTokenClaims
+import org.nypl.simplified.books.audio.AudioBookCredentials
+import org.readium.r2.shared.util.http.HttpRequest
+import java.util.UUID
+
+internal class FeedbooksTokenFactory(
+ private val configuration: FeedbooksPlayerExtensionConfiguration
+) {
+
+ fun createToken(request: HttpRequest): AudioBookCredentials.BearerToken {
+
+ val tokenHeader =
+ JOSEHeader(
+ mapOf(
+ Pair("alg", "HS256"),
+ Pair("typ", "JWT")
+ )
+ )
+
+ val tokenClaims =
+ JSONWebTokenClaims(
+ mapOf(
+ Pair("iss", configuration.issuerURL),
+ Pair("sub", request.url),
+ Pair("jti", UUID.randomUUID().toString())
+ )
+ )
+
+ val token =
+ JSONWebSignature.create(
+ algorithm = JSONWebSignatureAlgorithmHMACSha256.withSecret(
+ configuration.bearerTokenSecret
+ ),
+ header = tokenHeader,
+ payload = tokenClaims
+ )
+
+ return AudioBookCredentials.BearerToken(token.encode())
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestFetcher.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestFetcher.kt
new file mode 100644
index 000000000..bdb46a6a4
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestFetcher.kt
@@ -0,0 +1,79 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.readium.r2.shared.fetcher.FailureResource
+import org.readium.r2.shared.fetcher.Fetcher
+import org.readium.r2.shared.fetcher.Resource
+import org.readium.r2.shared.publication.Link
+import org.readium.r2.shared.publication.Manifest
+import org.readium.r2.shared.util.Try
+
+internal class UpdateManifestFetcher(
+ private val childFetcher: Fetcher,
+ private val initialManifest: Manifest,
+ private val downloadManifest: suspend () -> Try
+) : Fetcher {
+
+ private var manifest: Try? = Try.success(initialManifest)
+
+ private val mutex = Mutex()
+
+ private suspend fun getManifest(): Try = mutex.withLock {
+ manifest
+ ?: downloadManifest()
+ .also { manifest = it }
+ }
+
+ private suspend fun invalidateManifest(): Unit = mutex.withLock {
+ manifest = null
+ }
+
+ override suspend fun links(): List =
+ initialManifest.readingOrder
+
+ override fun get(link: Link): Resource {
+ if (!link.expires) {
+ return childFetcher.get(link)
+ }
+
+ val index = link.readingOrderIndex
+ ?: return failureResource(link)
+
+ return UpdateManifestResource(
+ index,
+ link,
+ childFetcher,
+ ::getManifest,
+ ::invalidateManifest
+ )
+ }
+
+ override suspend fun close() {
+ childFetcher.close()
+ }
+
+ private fun failureResource(link: Link): FailureResource =
+ FailureResource(
+ link,
+ Resource.Exception.NotFound(
+ IllegalStateException("Expiring link with no readingOrder index.")
+ )
+ )
+
+ private val Link.readingOrderIndex: Int?
+ get() = properties["readingOrderIndex"] as? Int
+
+ private val Link.expires: Boolean
+ get() = (properties["expires"] as? Boolean) ?: false
+
+ companion object {
+
+ fun adaptReadingOrder(links: List): List {
+ return links.mapIndexed { index, link ->
+ val additionalProperties = mapOf("readingOrderIndex" to index)
+ link.addProperties(additionalProperties)
+ }
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResource.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResource.kt
new file mode 100644
index 000000000..77cfa9fe6
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResource.kt
@@ -0,0 +1,64 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.readium.r2.shared.fetcher.FailureResource
+import org.readium.r2.shared.fetcher.Fetcher
+import org.readium.r2.shared.fetcher.Resource
+import org.readium.r2.shared.fetcher.ResourceTry
+import org.readium.r2.shared.publication.Link
+import org.readium.r2.shared.publication.Manifest
+import org.readium.r2.shared.util.Try
+import org.readium.r2.shared.util.tryRecover
+
+internal class UpdateManifestResource(
+ private val index: Int,
+ private val fallbackLink: Link,
+ private val baseFetcher: Fetcher,
+ private val getManifest: suspend () -> Try,
+ private val invalidateManifest: suspend () -> Unit
+) : Resource {
+
+ private var baseResource: Resource? = null
+
+ private val mutex: Mutex = Mutex()
+
+ private suspend fun getBaseResource(): Resource =
+ baseResource ?: run {
+ getManifest().fold(
+ onSuccess = {
+ baseFetcher.get(it.readingOrder[index].href)
+ },
+ onFailure = {
+ val exception = Resource.Exception.Other(Exception("Couldn't get a fresh manifest.", it))
+ FailureResource(fallbackLink, exception)
+ }
+ ).also { baseResource = it }
+ }
+
+ private suspend fun invalidateManifestOnFailure(
+ runnable: suspend () -> ResourceTry
+ ): ResourceTry =
+ runnable().tryRecover {
+ invalidateManifest()
+ baseResource = null
+ runnable()
+ }
+
+ override suspend fun link(): Link = mutex.withLock {
+ getBaseResource().link()
+ }
+
+ override suspend fun length(): ResourceTry = mutex.withLock {
+ invalidateManifestOnFailure { getBaseResource().length() }
+ }
+
+ override suspend fun read(range: LongRange?): ResourceTry = mutex.withLock {
+ invalidateManifestOnFailure { getBaseResource().read(range) }
+ }
+
+ override suspend fun close() = mutex.withLock {
+ baseResource?.close()
+ Unit
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/LifecycleMediaSessionService.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/LifecycleMediaSessionService.kt
new file mode 100644
index 000000000..1d7995a5b
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/LifecycleMediaSessionService.kt
@@ -0,0 +1,60 @@
+package org.nypl.simplified.viewer.audiobook.session
+
+import androidx.media2.session.MediaSessionService
+import androidx.lifecycle.Lifecycle
+
+import androidx.annotation.CallSuper
+
+import android.content.Intent
+
+import android.os.IBinder
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ServiceLifecycleDispatcher
+
+/*
+ * Borrowed from
+ * https://android.googlesource.com/platform/frameworks/support/+/refs/heads/androidx-main/lifecycle/lifecycle-service/src/main/java/androidx/lifecycle/LifecycleService.java
+ */
+
+abstract class LifecycleMediaSessionService : MediaSessionService(), LifecycleOwner {
+
+ @Suppress("LeakingThis")
+ private val lifecycleDispatcher = ServiceLifecycleDispatcher(this)
+
+ @CallSuper
+ override fun onCreate() {
+ lifecycleDispatcher.onServicePreSuperOnCreate()
+ super.onCreate()
+ }
+
+ @CallSuper
+ override fun onBind(intent: Intent): IBinder? {
+ lifecycleDispatcher.onServicePreSuperOnBind()
+ return super.onBind(intent)
+ }
+
+ @CallSuper
+ override fun onStart(intent: Intent?, startId: Int) {
+ lifecycleDispatcher.onServicePreSuperOnStart()
+ super.onStart(intent, startId)
+ }
+
+ // this method is added only to annotate it with @CallSuper.
+ // In usual service super.onStartCommand is no-op, but in LifecycleService
+ // it results in mDispatcher.onServicePreSuperOnStart() call, because
+ // super.onStartCommand calls onStart().
+ @CallSuper
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ @CallSuper
+ override fun onDestroy() {
+ lifecycleDispatcher.onServicePreSuperOnDestroy()
+ super.onDestroy()
+ }
+
+ override fun getLifecycle(): Lifecycle {
+ return lifecycleDispatcher.lifecycle
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/ManifestDownloader.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/ManifestDownloader.kt
new file mode 100644
index 000000000..75da87309
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/ManifestDownloader.kt
@@ -0,0 +1,160 @@
+package org.nypl.simplified.viewer.audiobook.session
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import one.irradia.mime.vanilla.MIMEParser
+import org.librarysimplified.audiobook.api.PlayerUserAgent
+import org.librarysimplified.audiobook.manifest.api.PlayerManifest
+import org.librarysimplified.audiobook.manifest_fulfill.spi.ManifestFulfilled
+import org.librarysimplified.services.api.Services
+import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials
+import org.nypl.simplified.books.api.BookID
+import org.nypl.simplified.books.audio.AudioBookCredentials
+import org.nypl.simplified.books.audio.AudioBookManifestRequest
+import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
+import org.nypl.simplified.books.audio.AudioBookManifestStrategyType
+import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle
+import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType
+import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
+import org.nypl.simplified.taskrecorder.api.TaskResult
+import org.nypl.simplified.viewer.audiobook.AudioBookPlayerParameters
+import org.readium.r2.shared.util.Try
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.io.IOException
+import java.net.URI
+
+internal class ManifestDownloader(
+ private val profilesController: ProfilesControllerType,
+ private val strategies: AudioBookManifestStrategiesType,
+ private val networkConnectivity: NetworkConnectivityType,
+ private val cacheDirectory: File,
+) {
+ private val logger =
+ LoggerFactory.getLogger(ManifestDownloader::class.java)
+
+ suspend fun downloadManifest(
+ parameters: AudioBookPlayerParameters,
+ credentials: AccountAuthenticationCredentials?,
+ ): Try {
+ val strategy =
+ createManifestStrategy(
+ parameters, strategies,
+ { networkConnectivity.isNetworkAvailable },
+ credentials, cacheDirectory
+ )
+
+ return downloadManifest(parameters, strategy)
+ }
+
+ private suspend fun downloadManifest(
+ parameters: AudioBookPlayerParameters,
+ strategy: AudioBookManifestStrategyType
+ ): Try = withContext(Dispatchers.IO){
+ val manifestData = when (val strategyResult = strategy.execute()) {
+ is TaskResult.Success -> {
+ saveManifest(
+ profiles = profilesController,
+ bookId = parameters.bookID,
+ manifestURI = parameters.manifestURI,
+ manifest = strategyResult.result.fulfilled
+ )
+ strategyResult.result
+ }
+ is TaskResult.Failure -> {
+ val exception = IOException(strategyResult.message)
+ return@withContext Try.failure(exception)
+ }
+ }
+
+ return@withContext Try.success(manifestData.manifest)
+ }
+
+ /**
+ * Attempt to save a manifest in the books database.
+ */
+
+ private suspend fun saveManifest(
+ profiles: ProfilesControllerType,
+ bookId: BookID,
+ manifestURI: URI,
+ manifest: ManifestFulfilled
+ ) = withContext(Dispatchers.IO) {
+ val handle =
+ profiles.profileAccountForBook(bookId)
+ .bookDatabase
+ .entry(bookId)
+ .findFormatHandle(BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook::class.java)
+
+ val contentType = manifest.contentType
+ if (handle == null) {
+ logger.error(
+ "Bug: Book database entry has no audio book format handle", IllegalStateException()
+ )
+ return@withContext
+ }
+
+ if (!handle.formatDefinition.supports(contentType)) {
+ logger.error(
+ "Server delivered an unsupported content type: {}: ", contentType, IOException()
+ )
+ return@withContext
+ }
+ handle.copyInManifestAndURI(manifest.data, manifestURI)
+ }
+
+ /**
+ * Create a manifest strategy for the current parameters.
+ */
+
+ private fun createManifestStrategy(
+ parameters: AudioBookPlayerParameters,
+ strategies: AudioBookManifestStrategiesType,
+ isNetworkAvailable: () -> Boolean,
+ credentials: AccountAuthenticationCredentials?,
+ cacheDirectory: File
+ ): AudioBookManifestStrategyType {
+ val manifestContentType =
+ MIMEParser.parseRaisingException(parameters.manifestContentType)
+ val userAgent =
+ PlayerUserAgent(parameters.userAgent)
+
+ val audioBookCredentials =
+ when (credentials) {
+ is AccountAuthenticationCredentials.Basic -> {
+ if (credentials.password.value.isBlank()) {
+ AudioBookCredentials.UsernameOnly(
+ userName = credentials.userName.value
+ )
+ } else {
+ AudioBookCredentials.UsernamePassword(
+ userName = credentials.userName.value,
+ password = credentials.password.value
+ )
+ }
+ }
+ is AccountAuthenticationCredentials.OAuthWithIntermediary ->
+ AudioBookCredentials.BearerToken(credentials.accessToken)
+ is AccountAuthenticationCredentials.SAML2_0 ->
+ AudioBookCredentials.BearerToken(credentials.accessToken)
+ null ->
+ null
+ }
+
+ val request =
+ AudioBookManifestRequest(
+ targetURI = parameters.manifestURI,
+ contentType = manifestContentType,
+ userAgent = userAgent,
+ credentials = audioBookCredentials,
+ services = Services.serviceDirectory(),
+ isNetworkAvailable = isNetworkAvailable,
+ loadFallbackData = {
+ ManifestFulfilled(manifestContentType, parameters.manifestFile.readBytes())
+ },
+ cacheDirectory = cacheDirectory
+ )
+
+ return strategies.createStrategy(request)
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/PublicationAdapter.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/PublicationAdapter.kt
new file mode 100644
index 000000000..d1b3e1b94
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/PublicationAdapter.kt
@@ -0,0 +1,177 @@
+package org.nypl.simplified.viewer.audiobook.session
+
+import org.librarysimplified.audiobook.feedbooks.FeedbooksPlayerExtensionConfiguration
+import org.librarysimplified.audiobook.manifest.api.PlayerManifest
+import org.librarysimplified.audiobook.manifest.api.PlayerManifestLink
+import org.nypl.simplified.accounts.api.AccountAuthenticationCredentials
+import org.nypl.simplified.opds.core.OPDSAcquisitionFeedEntry
+import org.nypl.simplified.viewer.audiobook.AudioBookPlayerParameters
+import org.nypl.simplified.viewer.audiobook.protection.FeedbooksHttpClientFactory
+import org.nypl.simplified.viewer.audiobook.protection.UpdateManifestFetcher
+import org.readium.r2.shared.extensions.toMap
+import org.readium.r2.shared.fetcher.Fetcher
+import org.readium.r2.shared.fetcher.HttpFetcher
+import org.readium.r2.shared.fetcher.RoutingFetcher
+import org.readium.r2.shared.publication.Contributor
+import org.readium.r2.shared.publication.Link
+import org.readium.r2.shared.publication.LocalizedString
+import org.readium.r2.shared.publication.Manifest
+import org.readium.r2.shared.publication.Metadata
+import org.readium.r2.shared.publication.Properties
+import org.readium.r2.shared.publication.Publication
+import org.readium.r2.shared.publication.encryption.Encryption
+import org.readium.r2.shared.publication.encryption.encryption
+import org.readium.r2.shared.util.Href
+import org.readium.r2.shared.util.http.DefaultHttpClient
+
+internal class PublicationAdapter(
+ private val manifestDownloader: ManifestDownloader,
+ private val feedbooksConfiguration: FeedbooksPlayerExtensionConfiguration?
+) {
+
+ fun createPublication(
+ playerManifest: PlayerManifest,
+ parameters: AudioBookPlayerParameters,
+ credentials: AccountAuthenticationCredentials?
+ ): Publication {
+
+ val builder = when {
+ playerManifest.readingOrder.any { it.expires } ->
+ createOverdrivePublication(playerManifest, parameters, credentials)
+ else ->
+ createRegularPublication(playerManifest, parameters)
+ }
+
+ return builder.build()
+ }
+
+ private fun createOverdrivePublication(
+ playerManifest: PlayerManifest,
+ parameters: AudioBookPlayerParameters,
+ credentials: AccountAuthenticationCredentials?
+ ): Publication.Builder {
+ val manifest =
+ createOverdriveManifest(playerManifest, parameters.opdsEntry)
+ return Publication.Builder(
+ manifest = manifest,
+ fetcher = createOverdriveFetcher(manifest, parameters, credentials)
+ )
+ }
+
+ private fun createOverdriveManifest(
+ playerManifest: PlayerManifest,
+ opdsEntry: OPDSAcquisitionFeedEntry
+ ): Manifest {
+ val readingOrder = playerManifest.readingOrder
+ .filterIsInstance(PlayerManifestLink.LinkBasic::class.java)
+ .map { it.toLink() }
+
+ val adaptedReadingOrder = UpdateManifestFetcher.adaptReadingOrder(readingOrder)
+
+ return Manifest(
+ metadata = createMetadata(playerManifest, opdsEntry),
+ readingOrder = adaptedReadingOrder,
+ tableOfContents = adaptedReadingOrder
+ )
+ }
+
+ private fun createOverdriveFetcher(
+ manifest: Manifest,
+ parameters: AudioBookPlayerParameters,
+ credentials: AccountAuthenticationCredentials?
+ ): Fetcher {
+ return UpdateManifestFetcher(
+ HttpFetcher(
+ client = DefaultHttpClient()
+ ),
+ manifest
+ ) {
+ manifestDownloader.downloadManifest(parameters, credentials)
+ .map { createOverdriveManifest(it, parameters.opdsEntry) }
+ }
+ }
+
+ private fun createRegularPublication(
+ playerManifest: PlayerManifest,
+ parameters: AudioBookPlayerParameters,
+ ): Publication.Builder {
+ return Publication.Builder(
+ manifest = createRegularManifest(playerManifest, parameters.opdsEntry),
+ fetcher = createRegularFetcher()
+ )
+ }
+
+ private fun createRegularManifest(
+ playerManifest: PlayerManifest,
+ opdsEntry: OPDSAcquisitionFeedEntry
+ ): Manifest {
+ val readingOrder = playerManifest.readingOrder
+ .filterIsInstance(PlayerManifestLink.LinkBasic::class.java)
+ .map { it.toLink() }
+
+ return Manifest(
+ metadata = createMetadata(playerManifest, opdsEntry),
+ readingOrder = readingOrder,
+ tableOfContents = readingOrder
+ )
+ }
+
+ private fun createRegularFetcher(): Fetcher {
+ val fallbackRoute =
+ RoutingFetcher.Route(
+ HttpFetcher(
+ client = DefaultHttpClient()
+ )
+ ) { true }
+
+ val feedbooksRoute = feedbooksConfiguration?.let {
+ RoutingFetcher.Route(
+ HttpFetcher(
+ client = FeedbooksHttpClientFactory(it).createHttpClient()
+ )
+ ) { link -> link.properties.encryption?.scheme == feedbooksConfiguration.encryptionScheme } }
+
+ return RoutingFetcher(
+ listOfNotNull(
+ feedbooksRoute,
+ fallbackRoute
+ )
+ )
+ }
+
+ private fun createMetadata(
+ playerManifest: PlayerManifest,
+ opdsEntry: OPDSAcquisitionFeedEntry
+ ): Metadata {
+ val authors = opdsEntry.authors
+ .map { Contributor(it) }
+
+ return Metadata(
+ identifier = playerManifest.metadata.identifier,
+ localizedTitle = LocalizedString(opdsEntry.title),
+ authors = authors
+ )
+ }
+
+ private fun PlayerManifestLink.toLink(): Link {
+ val encryption = properties.encrypted?.let {
+ Encryption(
+ scheme = it.scheme,
+ algorithm = "dummy" //FIXME: algorithm is mandatory in RWPM, but not in PlayerManifest
+ )
+ }
+
+ var properties = Properties(mapOf("expires" to expires))
+ encryption?.let {
+ properties = properties.add(mapOf("encrypted" to it.toJSON().toMap()))
+ }
+
+ return Link(
+ title = title,
+ href = Href(hrefURI.toString()).string,
+ duration = duration,
+ type = type?.toString(),
+ properties = properties
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/SessionBuilder.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/SessionBuilder.kt
new file mode 100644
index 000000000..73970dc57
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/session/SessionBuilder.kt
@@ -0,0 +1,151 @@
+package org.nypl.simplified.viewer.audiobook.session
+
+import android.app.Application
+import android.app.PendingIntent
+import android.content.Intent
+import org.librarysimplified.audiobook.player.api.PlayerAudioEngineRequest
+import org.librarysimplified.audiobook.player.api.PlayerAudioEngines
+import org.librarysimplified.audiobook.player.api.PlayerBookID
+import org.librarysimplified.audiobook.api.PlayerUserAgent
+import org.nypl.simplified.books.audio.AudioBookFeedbooksSecretServiceType
+import org.nypl.simplified.books.audio.AudioBookManifestStrategiesType
+import org.nypl.simplified.books.book_database.api.BookDatabaseEntryFormatHandle
+import org.nypl.simplified.networkconnectivity.api.NetworkConnectivityType
+import org.nypl.simplified.profiles.controller.api.ProfilesControllerType
+import org.nypl.simplified.viewer.audiobook.R
+import org.nypl.simplified.viewer.audiobook.AudioBookPlayerContract
+import org.nypl.simplified.viewer.audiobook.AudioBookPlayerParameters
+import org.nypl.simplified.viewer.audiobook.AudioBookPlayerSessionHolder
+import org.readium.navigator.media2.ExperimentalMedia2
+import org.readium.navigator.media2.MediaNavigator
+import org.readium.r2.shared.publication.Locator
+import org.readium.r2.shared.util.Try
+import org.slf4j.LoggerFactory
+import java.io.File
+
+internal class SessionBuilder(
+ private val application: Application,
+ private val profilesController: ProfilesControllerType,
+ private val strategies: AudioBookManifestStrategiesType,
+ private val networkConnectivity: NetworkConnectivityType,
+ private val cacheDirectory: File,
+ feedbooksConfigService: AudioBookFeedbooksSecretServiceType?
+) {
+
+ private val logger =
+ LoggerFactory.getLogger(SessionBuilder::class.java)
+
+ private val manifestDownloader =
+ ManifestDownloader(
+ profilesController, strategies, networkConnectivity, cacheDirectory
+ )
+
+ private val readiumAdapter: PublicationAdapter =
+ PublicationAdapter(
+ manifestDownloader, feedbooksConfigService?.configuration
+ )
+
+
+ suspend fun open(
+ parameters: AudioBookPlayerParameters
+ ): Try {
+ return try {
+ val sessionHolder = openThrowing(parameters)
+ Try.success(sessionHolder)
+ } catch (e: Exception) {
+ Try.failure(e)
+ }
+ }
+
+ @OptIn(ExperimentalMedia2::class)
+ private suspend fun openThrowing(
+ parameters: AudioBookPlayerParameters
+ ): AudioBookPlayerSessionHolder {
+ val account =
+ profilesController.profileAccountForBook(parameters.bookID)
+
+ val credentials =
+ account
+ .loginState
+ .credentials
+
+ val formatHandle =
+ account
+ .bookDatabase
+ .entry(parameters.bookID)
+ .findFormatHandle(BookDatabaseEntryFormatHandle.BookDatabaseEntryFormatHandleAudioBook::class.java)
+ ?: throw Exception("Couldn't find a handle for the book.")
+
+ val playerManifest =
+ manifestDownloader.downloadManifest(parameters, credentials)
+ .getOrThrow()
+
+ val publication =
+ readiumAdapter.createPublication(playerManifest, parameters, credentials)
+
+ val bookId =
+ PlayerBookID.transform(playerManifest.metadata.identifier)
+
+ val playerFactory = PlayerAudioEngines.findBestFor(
+ PlayerAudioEngineRequest(
+ context = application,
+ bookID = bookId,
+ publication = publication,
+ manifest = playerManifest,
+ userAgent = PlayerUserAgent(parameters.userAgent),
+ filter = { true }
+ )
+ )
+
+ if (playerFactory == null) {
+ val title =
+ this.application.resources.getString(R.string.audio_book_player_error_engine_open)
+ throw IllegalStateException(title)
+ }
+
+ val player = playerFactory.createPlayer()
+
+ val initialLocator =
+ formatHandle.format.position
+ ?.let {
+ val currentLink = publication.readingOrder[it.chapter] //FIXME: what about part?
+ Locator(
+ href = currentLink.href,
+ type = currentLink.type.orEmpty(),
+ locations = Locator.Locations(fragments = listOf("t=${it.offsetMilliseconds}"))
+ )
+ }
+
+ logger.debug("Initial locator is $initialLocator.")
+
+
+ val navigator = MediaNavigator.create(
+ context = application,
+ publication = publication,
+ initialLocator = initialLocator,
+ player = player.sessionPlayer
+ ).getOrThrow()
+
+ val activityIntent = createSessionActivityIntent(parameters)
+ val mediaSession = navigator.session(application, activityIntent)
+
+ return AudioBookPlayerSessionHolder(
+ parameters = parameters,
+ navigator = navigator,
+ mediaSession = mediaSession,
+ player = player,
+ formatHandle = formatHandle
+ )
+ }
+
+ private fun createSessionActivityIntent(audioBookPlayerParameters: AudioBookPlayerParameters): PendingIntent {
+ // This intent will be triggered when the notification is clicked.
+ var flags = PendingIntent.FLAG_UPDATE_CURRENT
+ flags = flags or PendingIntent.FLAG_IMMUTABLE
+
+ val intent = AudioBookPlayerContract.createIntent(application, audioBookPlayerParameters)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+
+ return PendingIntent.getActivity(application, 0, intent, flags)
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/MockingSleepTimer.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/MockingSleepTimer.kt
new file mode 100644
index 000000000..44f621193
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/MockingSleepTimer.kt
@@ -0,0 +1,52 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import io.reactivex.Observable
+import io.reactivex.subjects.BehaviorSubject
+import org.joda.time.Duration
+
+/**
+ * A sleep timer that does nothing at all.
+ */
+
+class MockingSleepTimer : PlayerSleepTimerType {
+
+ private val events = BehaviorSubject.create()
+ private var running: PlayerSleepTimerType.Running? = null
+ private var closed: Boolean = false
+ private var paused: Boolean = false
+
+ override fun start(time: Duration?) {
+ this.running = PlayerSleepTimerType.Running(this.paused, time)
+ this.events.onNext(PlayerSleepTimerEvent.PlayerSleepTimerRunning(this.paused, time))
+ }
+
+ override fun cancel() {
+ this.events.onNext(PlayerSleepTimerEvent.PlayerSleepTimerCancelled(this.running?.duration))
+ }
+
+ override fun finish() {
+ this.events.onNext(PlayerSleepTimerEvent.PlayerSleepTimerFinished)
+ }
+
+ override fun pause() {
+ this.paused = true
+ }
+
+ override fun unpause() {
+ this.paused = false
+ }
+
+ override val status: Observable
+ get() = this.events
+
+ override fun close() {
+ this.closed = true
+ this.events.onComplete()
+ }
+
+ override val isClosed: Boolean
+ get() = this.closed
+
+ override val isRunning: PlayerSleepTimerType.Running?
+ get() = this.running
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimer.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimer.kt
new file mode 100644
index 000000000..79fde8f20
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimer.kt
@@ -0,0 +1,361 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import io.reactivex.Observable
+import io.reactivex.subjects.BehaviorSubject
+import org.joda.time.Duration
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestClose
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestFinish
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestPause
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestStart
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestStop
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimer.PlayerTimerRequest.PlayerTimerRequestUnpause
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerCancelled
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerFinished
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerRunning
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerEvent.PlayerSleepTimerStopped
+import org.nypl.simplified.viewer.audiobook.timer.PlayerSleepTimerType.Running
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.annotation.concurrent.ThreadSafe
+
+/**
+ * The primary implementation of the {@link PlayerSleepTimerType} interface.
+ *
+ * The implementation is thread-safe, and a given instance may be used from any thread.
+ */
+
+@ThreadSafe
+class PlayerSleepTimer private constructor(
+ private val statusEvents: BehaviorSubject,
+ private val executor: ExecutorService
+) : PlayerSleepTimerType {
+
+ /**
+ * The type of requests that can be made to the timer.
+ */
+
+ private sealed class PlayerTimerRequest {
+
+ /**
+ * Request that the timer be closed.
+ */
+
+ object PlayerTimerRequestClose : PlayerTimerRequest()
+
+ /**
+ * Request that the timer be paused.
+ */
+
+ object PlayerTimerRequestPause : PlayerTimerRequest()
+
+ /**
+ * Request that the timer be unpaused.
+ */
+
+ object PlayerTimerRequestUnpause : PlayerTimerRequest()
+
+ /**
+ * Request that the timer finish (as if the duration had elapsed).
+ */
+
+ object PlayerTimerRequestFinish : PlayerTimerRequest()
+
+ /**
+ * Request that the timer start now and count down over the given duration. If the timer
+ * is already running, the timer is restarted.
+ */
+
+ class PlayerTimerRequestStart(
+ val duration: Duration?
+ ) : PlayerTimerRequest()
+
+ /**
+ * Request that the timer stop.
+ */
+
+ object PlayerTimerRequestStop : PlayerTimerRequest()
+ }
+
+ private val log: Logger = LoggerFactory.getLogger(PlayerSleepTimer::class.java)
+ private val closed: AtomicBoolean = AtomicBoolean(false)
+ private val taskFuture: Future<*>
+ private val task: PlayerSleepTimerTask
+ private val requests: ArrayBlockingQueue = ArrayBlockingQueue(16)
+
+ init {
+ this.log.debug("starting initial task")
+ this.task = PlayerSleepTimerTask(this)
+ this.taskFuture = this.executor.submit(this.task)
+ this.log.debug("waiting for task to start")
+ this.task.latch.await()
+ }
+
+ /**
+ * A sleep timer task that runs for as long as the sleep timer exists. The task is
+ * terminated when the sleep timer is closed.
+ */
+
+ private class PlayerSleepTimerTask(
+ private val timer: PlayerSleepTimer
+ ) : Runnable {
+
+ internal val latch: CountDownLatch = CountDownLatch(1)
+
+ private val log: Logger = LoggerFactory.getLogger(PlayerSleepTimerTask::class.java)
+ private var paused: Boolean = false
+
+ @Volatile
+ internal var running: Running? = null
+ private val oneSecond = Duration.standardSeconds(1L)
+
+ init {
+ this.log.debug("created timer task")
+ }
+
+ override fun run() {
+ this.log.debug("starting main task")
+ this.latch.countDown()
+
+ try {
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+
+ initialRequestWaiting@ while (true) {
+ try {
+ this.running = null
+ this.log.debug("waiting for timer requests")
+
+ /*
+ * Wait indefinitely (or at least until the thread is interrupted) for an initial
+ * request.
+ */
+
+ var initialRequest: PlayerTimerRequest?
+ try {
+ initialRequest = this.timer.requests.take()
+ } catch (e: InterruptedException) {
+ initialRequest = null
+ }
+
+ if (this.timer.isClosed) {
+ return
+ }
+
+ when (initialRequest) {
+ null, PlayerTimerRequestClose -> {
+ return
+ }
+
+ is PlayerTimerRequestStart -> {
+ this.log.debug("received start request: {}", initialRequest.duration)
+ this.running = Running(this.paused, initialRequest.duration)
+ this.timer.statusEvents.onNext(
+ PlayerSleepTimerRunning(this.paused, initialRequest.duration)
+ )
+ }
+
+ PlayerTimerRequestPause -> {
+ this.log.debug("received pause request")
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+ continue@initialRequestWaiting
+ }
+
+ PlayerTimerRequestUnpause -> {
+ this.log.debug("received unpause request")
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+ continue@initialRequestWaiting
+ }
+
+ PlayerTimerRequestStop -> {
+ this.log.debug("received (redundant) stop request")
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+ continue@initialRequestWaiting
+ }
+
+ PlayerTimerRequestFinish -> {
+ this.log.debug("received finish request")
+ this.timer.statusEvents.onNext(PlayerSleepTimerFinished)
+ continue@initialRequestWaiting
+ }
+ }
+
+ /*
+ * The timer is now running. Wait in a loop for requests. Time out waiting after a second
+ * each time in order to decrement the remaining time.
+ */
+
+ processingTimerRequests@ while (true) {
+
+ var request: PlayerTimerRequest?
+ try {
+ request = this.timer.requests.poll(1L, TimeUnit.SECONDS)
+ } catch (e: InterruptedException) {
+ request = null
+ }
+
+ when (request) {
+ null -> {
+ val currentRemaining = this.running?.duration
+ if (this.paused) {
+ this.running = Running(paused = true, duration = currentRemaining)
+ this.timer.statusEvents.onNext(
+ PlayerSleepTimerRunning(this.paused, currentRemaining)
+ )
+ continue@processingTimerRequests
+ }
+
+ if (currentRemaining != null) {
+ val newRemaining = currentRemaining.minus(this.oneSecond)
+ this.running = Running(this.paused, newRemaining)
+ this.timer.statusEvents.onNext(
+ PlayerSleepTimerRunning(this.paused, newRemaining)
+ )
+
+ if (newRemaining.isShorterThan(this.oneSecond)) {
+ this.log.debug("timer finished")
+ this.timer.statusEvents.onNext(PlayerSleepTimerFinished)
+ continue@initialRequestWaiting
+ }
+ }
+ }
+
+ PlayerTimerRequestClose -> {
+ return
+ }
+
+ is PlayerTimerRequestStart -> {
+ this.log.debug("restarting timer")
+ this.paused = false
+ this.running = Running(this.paused, request.duration)
+ this.timer.statusEvents.onNext(
+ PlayerSleepTimerRunning(this.paused, request.duration)
+ )
+ continue@processingTimerRequests
+ }
+
+ PlayerTimerRequestStop -> {
+ this.log.debug("stopping timer")
+ this.paused = false
+ this.timer.statusEvents.onNext(PlayerSleepTimerCancelled(this.running?.duration))
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+ continue@initialRequestWaiting
+ }
+
+ PlayerTimerRequestFinish -> {
+ this.log.debug("received finish request")
+ this.paused = false
+ this.timer.statusEvents.onNext(PlayerSleepTimerFinished)
+ continue@initialRequestWaiting
+ }
+
+ PlayerTimerRequestPause -> {
+ this.log.debug("received pause request")
+ this.paused = true
+ this.running = this.running ?.copy(paused = this.paused)
+ continue@processingTimerRequests
+ }
+
+ PlayerTimerRequestUnpause -> {
+ this.log.debug("received unpause request")
+ this.paused = false
+ this.running = this.running ?.copy(paused = this.paused)
+ continue@processingTimerRequests
+ }
+ }
+ }
+ } catch (e: Exception) {
+ this.log.error("error processing request: ", e)
+ }
+ }
+ } finally {
+ this.log.debug("stopping main task")
+ this.log.debug("completing status events")
+ this.timer.statusEvents.onNext(PlayerSleepTimerStopped)
+ this.timer.statusEvents.onComplete()
+ }
+ }
+ }
+
+ companion object {
+
+ private val log: Logger = LoggerFactory.getLogger(PlayerSleepTimer::class.java)
+
+ /**
+ * Create a new sleep timer.
+ */
+
+ fun create(): PlayerSleepTimerType {
+ return PlayerSleepTimer(
+ executor = Executors.newFixedThreadPool(1) { run -> createTimerThread(run) },
+ statusEvents = BehaviorSubject.create()
+ )
+ }
+
+ /**
+ * Create a thread suitable for use with the ExoPlayer audio engine.
+ */
+
+ private fun createTimerThread(r: Runnable?): Thread {
+ val thread = PlayerSleepTimerThread(r ?: Runnable { })
+ log.debug("created timer thread: {}", thread.name)
+ thread.setUncaughtExceptionHandler { t, e ->
+ log.error("uncaught exception on engine thread {}: ", t, e)
+ }
+ return thread
+ }
+ }
+
+ private fun checkIsNotClosed() {
+ if (this.isClosed) {
+ throw IllegalStateException("Timer has been closed")
+ }
+ }
+
+ override fun start(time: Duration?) {
+ this.checkIsNotClosed()
+ this.requests.offer(PlayerTimerRequestStart(time), 10L, TimeUnit.MILLISECONDS)
+ }
+
+ override fun cancel() {
+ this.checkIsNotClosed()
+ this.requests.offer(PlayerTimerRequestStop, 10L, TimeUnit.MILLISECONDS)
+ }
+
+ override fun finish() {
+ this.checkIsNotClosed()
+ this.requests.offer(PlayerTimerRequestFinish, 10L, TimeUnit.MILLISECONDS)
+ }
+
+ override fun close() {
+ if (this.closed.compareAndSet(false, true)) {
+ this.taskFuture.cancel(true)
+ this.executor.shutdown()
+ this.requests.offer(PlayerTimerRequestClose, 10L, TimeUnit.MILLISECONDS)
+ }
+ }
+
+ override fun pause() {
+ this.checkIsNotClosed()
+ this.requests.offer(PlayerTimerRequestPause, 10L, TimeUnit.MILLISECONDS)
+ }
+
+ override fun unpause() {
+ this.checkIsNotClosed()
+ this.requests.offer(PlayerTimerRequestUnpause, 10L, TimeUnit.MILLISECONDS)
+ }
+
+ override val isClosed: Boolean
+ get() = this.closed.get()
+
+ override val status: Observable =
+ this.statusEvents.distinctUntilChanged()
+
+ override val isRunning: Running?
+ get() = this.task.running
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerEvent.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerEvent.kt
new file mode 100644
index 000000000..f76694484
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerEvent.kt
@@ -0,0 +1,42 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import org.joda.time.Duration
+
+/**
+ * The type of sleep timer events.
+ */
+
+sealed class PlayerSleepTimerEvent {
+
+ /**
+ * The sleep timer is stopped. This is the initial state.
+ */
+
+ object PlayerSleepTimerStopped : PlayerSleepTimerEvent()
+
+ /**
+ * The sleep timer is currently running. This state will be published frequently while the sleep
+ * timer is counting down. If a duration was specified when the timer was started, the given
+ * duration indicates the amount of time remaining.
+ */
+
+ data class PlayerSleepTimerRunning(
+ val paused: Boolean,
+ val remaining: Duration?
+ ) : PlayerSleepTimerEvent()
+
+ /**
+ * The user cancelled the sleep timer countdown. If a duration was specified when the timer was
+ * started, the given duration indicates the amount of time remaining.
+ */
+
+ data class PlayerSleepTimerCancelled(
+ val remaining: Duration?
+ ) : PlayerSleepTimerEvent()
+
+ /**
+ * The sleep timer ran to completion.
+ */
+
+ object PlayerSleepTimerFinished : PlayerSleepTimerEvent()
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerThread.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerThread.kt
new file mode 100644
index 000000000..99c065c92
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerThread.kt
@@ -0,0 +1,34 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+/**
+ * A sleep timer thread. The purpose of this class is to allow for efficient checks of the form
+ * "is the current thread a sleep timer thread?". Specifically, if the current thread is an instance
+ * of PlayerSleepTimerThread, then the current thread is a timer thread.
+ */
+
+class PlayerSleepTimerThread(runnable: Runnable) : Thread(runnable) {
+
+ init {
+ this.name = "org.librarysimplified.audiobook.api:timer:${this.id}"
+ }
+
+ companion object {
+
+ fun isSleepTimerThread(): Boolean {
+ return Thread.currentThread() is PlayerSleepTimerThread
+ }
+
+ fun checkIsSleepTimerThread() {
+ if (!isSleepTimerThread()) {
+ throw IllegalStateException(
+ StringBuilder(128)
+ .append("Current thread is not a sleep timer thread!\n")
+ .append(" Thread: ")
+ .append(Thread.currentThread())
+ .append('\n')
+ .toString()
+ )
+ }
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerType.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerType.kt
new file mode 100644
index 000000000..ec8647a57
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/timer/PlayerSleepTimerType.kt
@@ -0,0 +1,106 @@
+package org.nypl.simplified.viewer.audiobook.timer
+
+import io.reactivex.Observable
+import org.joda.time.Duration
+import javax.annotation.concurrent.ThreadSafe
+
+/**
+ * The interface exposed by sleep timer implementations.
+ *
+ * Implementations of this interface are required to be thread-safe. That is, methods and properties
+ * may be safely called/accessed from any thread.
+ */
+
+@ThreadSafe
+interface PlayerSleepTimerType : AutoCloseable {
+
+ /**
+ * Start the timer. If a duration is given, the timer will count down over the given duration
+ * and will periodically publish events giving the remaining time. If no duration is given, the
+ * timer will wait indefinitely for a call to {@link #finish()}. If the timer is paused, the
+ * timer will be unpaused.
+ *
+ * @param time The total duration for which the timer will run
+ *
+ * @throws java.lang.IllegalStateException If and only if the player is closed
+ */
+
+ @Throws(java.lang.IllegalStateException::class)
+ fun start(time: Duration?)
+
+ /**
+ * Cancel the timer. The timer will stop and will publish an event indicating the current
+ * state.
+ *
+ * @throws java.lang.IllegalStateException If and only if the player is closed
+ */
+
+ @Throws(java.lang.IllegalStateException::class)
+ fun cancel()
+
+ /**
+ * Pause the timer. The timer will pause and will publish an event indicating the current
+ * state.
+ *
+ * @throws java.lang.IllegalStateException If and only if the player is closed
+ */
+
+ @Throws(java.lang.IllegalStateException::class)
+ fun pause()
+
+ /**
+ * Unpause the timer. The timer will unpause and will publish an event indicating the current
+ * state.
+ *
+ * @throws java.lang.IllegalStateException If and only if the player is closed
+ */
+
+ @Throws(java.lang.IllegalStateException::class)
+ fun unpause()
+
+ /**
+ * Finish the timer. This makes the timer behave exactly as if a duration had been given to
+ * start and the duration has elapsed. If the timer is paused, the timer will be unpaused.
+ *
+ * @throws java.lang.IllegalStateException If and only if the player is closed
+ */
+
+ @Throws(java.lang.IllegalStateException::class)
+ fun finish()
+
+ /**
+ * An observable indicating the current state of the timer. The observable is buffered such
+ * that each new subscription will receive the most recently published status event, and will
+ * then receive new status events as they are published.
+ */
+
+ val status: Observable
+
+ /**
+ * Close the timer. After this method is called, it is an error to call any of the other methods
+ * in the interface.
+ */
+
+ override fun close()
+
+ /**
+ * @return `true` if the timer has been closed.
+ */
+
+ val isClosed: Boolean
+
+ /**
+ * A type indicating that the timer is currently running.
+ */
+
+ data class Running(
+ val paused: Boolean,
+ val duration: Duration?
+ )
+
+ /**
+ * A non-null value of type {@link Running} if the timer is running, and null if it is not.
+ */
+
+ val isRunning: Running?
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerBottomBar.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerBottomBar.kt
new file mode 100644
index 000000000..b323fb090
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerBottomBar.kt
@@ -0,0 +1,98 @@
+package org.nypl.simplified.viewer.audiobook.ui.components
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.BottomAppBar
+import androidx.compose.material.BottomNavigationItem
+import androidx.compose.material.Icon
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.nypl.simplified.viewer.audiobook.R
+import org.nypl.simplified.viewer.audiobook.ui.util.CenteredOverlay
+import org.nypl.simplified.viewer.audiobook.ui.util.asTextUnit
+
+@Composable
+internal fun PlayerBottomBar(
+ onSpeedClicked: () -> Unit,
+ onSleepClicked: () -> Unit,
+ onChaptersClicked: () -> Unit
+) {
+ BottomAppBar {
+ SpeedButton(onSleepClicked)
+ SleepButton(onSleepClicked)
+ ChaptersButton(onChaptersClicked)
+
+ }
+}
+
+@Composable
+private fun (RowScope).SpeedButton(
+ onClick: () -> Unit
+) {
+ MenuItem(
+ onClick = onClick,
+ label = "Speed",
+ iconResourceId = R.drawable.speed,
+ contentDescription = "Speed",
+ overlay = {
+ Text(
+ text = "1.0x",
+ fontSize = 7.dp.asTextUnit(),
+ textAlign = TextAlign.Center
+ )
+ }
+ )
+}
+
+@Composable
+private fun (RowScope).SleepButton(
+ onClick: () -> Unit
+) {
+ MenuItem(
+ onClick = onClick,
+ label = "Sleep",
+ iconResourceId = R.drawable.sleep_icon,
+ contentDescription = "Sleep"
+ )
+}
+
+@Composable
+private fun (RowScope).ChaptersButton(
+ onClick: () -> Unit
+) {
+ MenuItem(
+ onClick = onClick,
+ label = "Chapters",
+ iconResourceId = R.drawable.list,
+ contentDescription = "Chapters"
+ )
+}
+
+@Composable
+private fun (RowScope).MenuItem(
+ onClick: () -> Unit,
+ label: String,
+ @DrawableRes iconResourceId: Int,
+ contentDescription: String?,
+ overlay: (@Composable () -> Unit)? = null,
+) {
+ BottomNavigationItem(
+ selected = false,
+ onClick = onClick,
+ label = { Text(label) },
+ icon = {
+ CenteredOverlay(overlay = overlay) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ painter = painterResource(id = iconResourceId),
+ contentDescription = contentDescription
+ )
+ }
+ }
+ )
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerControls.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerControls.kt
new file mode 100644
index 000000000..234b5fcfb
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerControls.kt
@@ -0,0 +1,105 @@
+package org.nypl.simplified.viewer.audiobook.ui.components
+
+import androidx.annotation.DrawableRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.nypl.simplified.viewer.audiobook.R
+import org.nypl.simplified.viewer.audiobook.ui.util.CenteredOverlay
+import org.nypl.simplified.viewer.audiobook.ui.util.asTextUnit
+
+@Composable
+internal fun PlayerControls(
+ modifier: Modifier,
+ showPause: Boolean,
+ onSkipForward: () -> Unit,
+ onSkipBackward: () -> Unit,
+ onPlay: () -> Unit,
+ onPause: () -> Unit,
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(35.dp)
+ ) {
+ BackwardButton(onSkipBackward)
+ PlayPauseButton(onPlay, onPause, showPause)
+ ForwardButton(onSkipForward)
+ }
+}
+
+@Composable
+private fun PlayPauseButton(
+ onPlay: () -> Unit,
+ onPause: () -> Unit,
+ showPause: Boolean
+) {
+ ControlButton(
+ onClick = if (showPause) onPause else onPlay,
+ iconResourceId = if (showPause) R.drawable.pause_icon else R.drawable.play_icon,
+ overlay = null,
+ contentDescription = if (showPause) "Pause" else "Play"
+ )
+}
+
+@Composable
+private fun BackwardButton(
+ onClick: () -> Unit,
+) {
+ ControlButton(
+ onClick = onClick,
+ iconResourceId = R.drawable.circle_arrow_backward,
+ overlay = { SkipTextOverlay() } ,
+ contentDescription = "Go backward"
+ )
+}
+
+@Composable
+private fun ForwardButton(
+ onClick: () -> Unit,
+) {
+ ControlButton(
+ onClick = onClick,
+ iconResourceId = R.drawable.circle_arrow_forward,
+ overlay = { SkipTextOverlay() },
+ contentDescription = "Go forward"
+ )
+}
+
+@Composable
+private fun SkipTextOverlay() {
+ Text(
+ text = "15",
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.dp.asTextUnit(),
+ textAlign = TextAlign.Center
+ )
+}
+
+@Composable
+private fun ControlButton(
+ onClick: () -> Unit,
+ @DrawableRes iconResourceId: Int,
+ overlay: (@Composable () -> Unit)?,
+ contentDescription: String?
+) {
+ IconButton(
+ onClick = onClick
+ ) {
+ CenteredOverlay(overlay = overlay) {
+ Icon(
+ modifier = Modifier.size(48.dp),
+ painter = painterResource(id = iconResourceId),
+ contentDescription = contentDescription
+ )
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerProgression.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerProgression.kt
new file mode 100644
index 000000000..51749e46a
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/components/PlayerProgression.kt
@@ -0,0 +1,95 @@
+package org.nypl.simplified.viewer.audiobook.ui.components
+
+import android.text.format.DateUtils
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.Slider
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+
+@Composable
+internal fun PlayerProgression(
+ modifier: Modifier,
+ position: Duration,
+ duration: Duration,
+ onPositionChange: (Duration) -> Unit
+) {
+ var slidingPosition by remember { mutableStateOf(null) }
+ val displayedPosition = slidingPosition ?: position
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(5.dp)
+ ) {
+ ProgressionBar(
+ modifier = Modifier,
+ position = displayedPosition,
+ duration = duration,
+ onValueChange = {
+ slidingPosition = it
+ },
+ onValueChangeFinished = {
+ val newPosition = checkNotNull(slidingPosition)
+ slidingPosition = null
+ onPositionChange(newPosition)
+ }
+ )
+
+ ProgressionText(
+ position = displayedPosition,
+ duration = duration
+ )
+ }
+}
+
+@Composable
+private fun ProgressionBar(
+ modifier: Modifier,
+ position: Duration,
+ duration: Duration,
+ onValueChange: (Duration) -> Unit,
+ onValueChangeFinished: () -> Unit
+) {
+ Slider(
+ value = position.inWholeSeconds.toFloat(),
+ modifier = modifier,
+ valueRange = 0f..duration.inWholeSeconds.toFloat(),
+ enabled = true,
+ steps = 0,
+ onValueChangeFinished = onValueChangeFinished,
+ onValueChange = { onValueChange(it.toDouble().seconds) }
+ )
+}
+
+@Composable
+private fun ProgressionText(
+ position: Duration,
+ duration: Duration
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = position.formatElapsedTime(), fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = duration.formatElapsedTime()
+ )
+ }
+}
+
+private fun Duration.formatElapsedTime(): String =
+ DateUtils.formatElapsedTime(toLong(DurationUnit.SECONDS))
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Backstack.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Backstack.kt
new file mode 100644
index 000000000..f0cdd7067
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Backstack.kt
@@ -0,0 +1,62 @@
+package org.nypl.simplified.viewer.audiobook.ui.navigation
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * A stack of screens which is never empty.
+ */
+internal class Backstack(
+ initialScreen: T
+) {
+
+ /**
+ * All the screens in the stack, from bottom to top.
+ */
+ val screens: MutableList =
+ mutableListOf(initialScreen)
+
+ /**
+ * The screen currently at the top of the stack
+ */
+ val current: StateFlow
+ get() = currentMutableState
+
+ /**
+ * The number of screens in the stack
+ */
+ val size: Int
+ get() = screens.size
+
+ private val currentMutableState: MutableStateFlow =
+ MutableStateFlow(initialScreen)
+
+ /**
+ * Add a screen on to of the backstack
+ */
+ fun add(screen: T) {
+ screens.add(screen)
+ currentMutableState.value = screen
+ }
+
+ /**
+ * Replace the current fragment on top of the backstack
+ */
+ fun replace (screen: T) {
+ screens.removeLast()
+ screens.add(screen)
+ currentMutableState.value = screen
+ }
+
+ /**
+ * Pop the top screen from the backstack.
+ *
+ * Nothing will happen if doing so would lead to an empty backstack.
+ */
+ fun pop() {
+ if (size > 1) {
+ screens.removeLast()
+ currentMutableState.value = screens.last()
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Listener.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Listener.kt
new file mode 100644
index 000000000..0576aae35
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Listener.kt
@@ -0,0 +1,51 @@
+package org.nypl.simplified.viewer.audiobook.ui.navigation
+
+import kotlinx.coroutines.flow.StateFlow
+import org.nypl.simplified.viewer.audiobook.ui.screens.ContentsScreenListener
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreenListener
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreenState
+import org.readium.r2.shared.publication.Link
+
+internal class Listener
+ : PlayerScreenListener, ContentsScreenListener {
+
+ private val backstack: Backstack =
+ Backstack(Screen.Loading)
+
+ private val playerState: PlayerScreenState
+ get() = backstack.screens
+ .filterIsInstance(Screen.Player::class.java)
+ .first().state
+
+ val currentScreen: StateFlow
+ get() = backstack.current
+
+ fun onBackstackPressed(): Boolean {
+ if (backstack.size > 1) {
+ backstack.pop()
+ return true
+ }
+
+ return false
+ }
+
+ fun onPlayerReady(state: PlayerScreenState) {
+ backstack.replace(Screen.Player(state, this))
+ }
+
+ fun onLoadingException(error: Throwable) {
+ backstack.replace(Screen.Error(error))
+ }
+
+ override fun onOpenToc() {
+ val links = playerState.readingOrder
+ val contents = Screen.Contents(links, this)
+ backstack.add(contents)
+ }
+
+ override fun onTocItemCLicked(link: Link) {
+ playerState.go(link)
+ playerState.play()
+ backstack.pop()
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Screen.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Screen.kt
new file mode 100644
index 000000000..598215173
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/navigation/Screen.kt
@@ -0,0 +1,57 @@
+package org.nypl.simplified.viewer.audiobook.ui.navigation
+
+import androidx.compose.runtime.Composable
+import org.nypl.simplified.viewer.audiobook.ui.screens.ContentsScreen
+import org.nypl.simplified.viewer.audiobook.ui.screens.ContentsScreenListener
+import org.nypl.simplified.viewer.audiobook.ui.screens.ErrorScreen
+import org.nypl.simplified.viewer.audiobook.ui.screens.LoadingScreen
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreen
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreenListener
+import org.nypl.simplified.viewer.audiobook.ui.screens.PlayerScreenState
+import org.readium.r2.shared.publication.Link
+
+internal sealed class Screen {
+
+ @Composable
+ abstract fun Screen()
+
+ object Loading : Screen() {
+
+ @Composable
+ override fun Screen() {
+ LoadingScreen()
+ }
+ }
+
+ class Error(
+ private val exception: Throwable
+ ) : Screen() {
+
+ @Composable
+ override fun Screen() {
+ ErrorScreen(exception)
+ }
+ }
+
+ class Player(
+ val state: PlayerScreenState,
+ private val listener: PlayerScreenListener
+ ) : Screen() {
+
+ @Composable
+ override fun Screen() {
+ PlayerScreen(state, listener)
+ }
+ }
+
+ class Contents(
+ private val links: List,
+ private val listener: ContentsScreenListener
+ ) : Screen() {
+
+ @Composable
+ override fun Screen() {
+ ContentsScreen(links, listener)
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ContentsScreen.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ContentsScreen.kt
new file mode 100644
index 000000000..05c51d118
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ContentsScreen.kt
@@ -0,0 +1,88 @@
+package org.nypl.simplified.viewer.audiobook.ui.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.Divider
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.nypl.simplified.viewer.audiobook.R
+import org.readium.r2.shared.publication.Link
+
+internal interface ContentsScreenListener {
+
+ fun onTocItemCLicked(link: Link)
+}
+
+@Composable
+internal fun ContentsScreen(
+ links: List,
+ listener: ContentsScreenListener
+) {
+ Scaffold { paddingValues ->
+ Box(
+ modifier = Modifier
+ .padding(paddingValues)
+ ) {
+ Contents(
+ modifier = Modifier
+ .fillMaxSize(),
+ links = links,
+ onItemClick = { listener.onTocItemCLicked(it) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun Contents(
+ modifier: Modifier = Modifier,
+ links: List,
+ onItemClick: (Link) -> Unit
+) {
+ LazyColumn(
+ modifier = modifier
+ ) {
+ itemsIndexed(links, key = null) { index, link ->
+ TocItem(
+ modifier = Modifier.fillMaxWidth(),
+ index = index,
+ link = link,
+ onClick = { onItemClick(link) }
+ )
+ Divider()
+ }
+ }
+}
+
+@Composable
+private fun TocItem(
+ modifier: Modifier,
+ index: Int,
+ link: Link,
+ onClick: () -> Unit
+) {
+ Box(
+ modifier = modifier
+ .height(80.dp)
+ .clickable { onClick() }
+ .padding(15.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Text(
+ modifier = Modifier,
+ text = link.title
+ ?: stringResource(R.string.audio_book_player_toc_chapter_n, index),
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ErrorScreen.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ErrorScreen.kt
new file mode 100644
index 000000000..1c98a1a80
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/ErrorScreen.kt
@@ -0,0 +1,18 @@
+package org.nypl.simplified.viewer.audiobook.ui.screens
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+internal fun ErrorScreen(exception: Throwable) {
+ Box(Modifier.fillMaxSize()) {
+ Text(
+ modifier = Modifier.align(Alignment.Center),
+ text = exception.message ?: "Failed to load audiobook."
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/LoadingScreen.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/LoadingScreen.kt
new file mode 100644
index 000000000..350cf232b
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/LoadingScreen.kt
@@ -0,0 +1,17 @@
+package org.nypl.simplified.viewer.audiobook.ui.screens
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+internal fun LoadingScreen() {
+ Box(Modifier.fillMaxSize()) {
+ LinearProgressIndicator(
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreen.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreen.kt
new file mode 100644
index 000000000..2876c818c
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreen.kt
@@ -0,0 +1,119 @@
+package org.nypl.simplified.viewer.audiobook.ui.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.nypl.simplified.viewer.audiobook.ui.components.PlayerBottomBar
+import org.nypl.simplified.viewer.audiobook.ui.components.PlayerControls
+import org.nypl.simplified.viewer.audiobook.ui.components.PlayerProgression
+import org.readium.navigator.media2.ExperimentalMedia2
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+
+internal interface PlayerScreenListener {
+
+ fun onOpenToc()
+}
+
+@Composable
+internal fun PlayerScreen(
+ audioBookPlayerState: PlayerScreenState,
+ listener: PlayerScreenListener
+) {
+ Scaffold(
+ bottomBar = {
+ PlayerBottomBar(
+ onSpeedClicked = {},
+ onSleepClicked = {},
+ onChaptersClicked = listener::onOpenToc
+ )
+ },
+ content = { paddingValues ->
+ Box(
+ modifier = Modifier
+ .padding(paddingValues),
+ propagateMinConstraints = true
+ ) {
+ PlayerContent(
+ modifier = Modifier
+ .padding(20.dp)
+ .fillMaxSize(),
+ audioBookPlayerState = audioBookPlayerState
+ )
+ }
+ }
+ )
+}
+
+@OptIn(ExperimentalTime::class, ExperimentalMedia2::class)
+@Composable
+internal fun PlayerContent(
+ modifier: Modifier,
+ audioBookPlayerState: PlayerScreenState,
+) {
+ val resource = audioBookPlayerState.resource.value
+ val paused = audioBookPlayerState.paused.value
+
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.SpaceEvenly,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ PlayerMetadata(
+ modifier = Modifier,
+ title = audioBookPlayerState.title,
+ author = audioBookPlayerState.author
+ )
+
+ PlayerProgression(
+ modifier = Modifier.fillMaxWidth(),
+ position = resource.position,
+ duration = resource.duration ?: Duration.ZERO,
+ onPositionChange = { audioBookPlayerState.seek(it) }
+ )
+
+ PlayerControls(
+ modifier = Modifier,
+ showPause = !paused,
+ onSkipBackward = audioBookPlayerState::goBackward,
+ onSkipForward = audioBookPlayerState::goForward,
+ onPlay = audioBookPlayerState::play,
+ onPause = audioBookPlayerState::pause
+ )
+ }
+}
+
+@Composable
+internal fun PlayerMetadata(
+ modifier: Modifier,
+ title: String,
+ author: String?
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = title, fontWeight = FontWeight.Bold)
+ author?.let { author ->
+ Text(text = author)
+ }
+ }
+}
+
+@Composable
+internal fun PlayerCover(
+
+) {
+
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreenState.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreenState.kt
new file mode 100644
index 000000000..1bb522694
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/screens/PlayerScreenState.kt
@@ -0,0 +1,152 @@
+package org.nypl.simplified.viewer.audiobook.ui.screens
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.readium.navigator.media2.ExperimentalMedia2
+import org.readium.navigator.media2.MediaNavigator
+import org.readium.r2.shared.publication.Link
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+
+
+@OptIn(ExperimentalMedia2::class, ExperimentalTime::class)
+internal class PlayerScreenState(
+ private val mediaNavigator: MediaNavigator,
+ private val navigatorScope: CoroutineScope
+) {
+ private val resourceMutable: MutableState =
+ mutableStateOf(mediaNavigator.playback.value.resource)
+
+ private val pausedMutable: MutableState =
+ mutableStateOf(mediaNavigator.playback.value.state == MediaNavigator.Playback.State.Playing)
+
+ private val errorMutable: MutableState =
+ mutableStateOf(null)
+
+ private var preventPlaybackUpdate: Boolean =
+ false
+
+ private val commandMutex: Mutex =
+ Mutex()
+
+ init {
+ mediaNavigator.playback
+ .onEach(this::onPlaybackChange)
+ .launchIn(navigatorScope)
+ }
+
+ private fun onPlaybackChange(playback: MediaNavigator.Playback) {
+ if (playback.state == MediaNavigator.Playback.State.Error) {
+ errorMutable.value = Exception("An error occurred in the player.")
+ }
+
+ if (!preventPlaybackUpdate) {
+ updatePlayback(playback)
+ }
+ }
+
+ private fun updatePlayback(playback: MediaNavigator.Playback) {
+ if (playback.resource != resource.value) {
+ resourceMutable.value = playback.resource
+ }
+
+ if ((playback.state == MediaNavigator.Playback.State.Paused) != paused.value) {
+ pausedMutable.value = playback.state == MediaNavigator.Playback.State.Paused
+ }
+ }
+
+ private fun executeCommand(block: suspend (MediaNavigator).() -> Unit) = navigatorScope.launch {
+ executeCommandAsync(block)
+ }
+
+ private suspend fun executeCommandAsync(block: suspend (MediaNavigator).() -> Unit) = commandMutex.withLock {
+ preventPlaybackUpdate = true
+ mediaNavigator.block()
+ preventPlaybackUpdate = false
+ onPlaybackChange(mediaNavigator.playback.value)
+ }
+
+
+ /**
+ * The title to display
+ */
+ val title: String =
+ mediaNavigator.publication.metadata.title
+
+ /**
+ * The author to display
+ */
+ val author: String? =
+ mediaNavigator.publication.metadata.authors.firstOrNull()?.name
+
+ /**
+ * The table of contents to display
+ */
+ val readingOrder: List =
+ mediaNavigator.publication.readingOrder
+
+ /**
+ * The reading item state to display
+ */
+ val resource: State
+ get() = resourceMutable
+
+ /**
+ * The play/pause state to display
+ */
+ val paused: State
+ get() = pausedMutable
+
+ /**
+ * An error to display or null if everything's fine
+ */
+ val error: State
+ get() = errorMutable
+
+ fun goPrevious() = executeCommand {
+ val currentIndex = resource.value.index
+ if (currentIndex > 0) {
+ go(mediaNavigator.publication.readingOrder[currentIndex - 1])
+ }
+ }
+
+ fun goNext() = executeCommand {
+ val currentIndex = resource.value.index
+ if (currentIndex + 1 < mediaNavigator.publication.readingOrder.size) {
+ mediaNavigator.go(mediaNavigator.publication.readingOrder[currentIndex + 1])
+ }
+ }
+
+ fun go(link: Link) = executeCommand {
+ mediaNavigator.go(link)
+ }
+
+ fun seek(position: Duration) = executeCommand {
+ resourceMutable.value = resource.value.copy(position = position)
+ val currentIndex = resource.value.index
+ mediaNavigator.seek(currentIndex, position)
+ }
+
+ fun play() = executeCommand {
+ mediaNavigator.play()
+ }
+
+ fun pause() = executeCommand {
+ mediaNavigator.pause()
+ }
+
+ fun goBackward() = executeCommand {
+ mediaNavigator.goBackward()
+ }
+
+ fun goForward() = executeCommand {
+ mediaNavigator.goForward()
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/Misc.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/Misc.kt
new file mode 100644
index 000000000..cf9e1ba2b
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/Misc.kt
@@ -0,0 +1,29 @@
+package org.nypl.simplified.viewer.audiobook.ui.util
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.sp
+
+@Composable
+internal fun Dp.asTextUnit(): TextUnit =
+ with(LocalDensity.current) {
+ val textSize = value / fontScale
+ textSize.sp
+ }
+
+@Composable
+internal fun CenteredOverlay(
+ overlay: (@Composable () -> Unit)?,
+ content: @Composable () -> Unit
+) {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ content()
+ overlay?.invoke()
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/PlayerPlaybackRate.kt b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/PlayerPlaybackRate.kt
new file mode 100644
index 000000000..c8ad19243
--- /dev/null
+++ b/simplified-viewer-audiobook/src/main/java/org/nypl/simplified/viewer/audiobook/ui/util/PlayerPlaybackRate.kt
@@ -0,0 +1,66 @@
+package org.nypl.simplified.viewer.audiobook.ui.util
+
+/**
+ * The playback rate of the player.
+ */
+
+enum class PlayerPlaybackRate(val speed: Double) {
+
+ /**
+ * 75% speed.
+ */
+
+ THREE_QUARTERS_TIME(0.75),
+
+ /**
+ * Normal speed.
+ */
+
+ NORMAL_TIME(1.0),
+
+ /**
+ * 125% speed.
+ */
+
+ ONE_AND_A_QUARTER_TIME(1.25),
+
+ /**
+ * 150% speed.
+ */
+
+ ONE_AND_A_HALF_TIME(1.50),
+
+ /**
+ * 200% speed.
+ */
+
+ DOUBLE_TIME(2.0);
+
+ /**
+ * @return The speed below this speed (or the current speed if there is no lower speed)
+ */
+
+ fun decrease(): PlayerPlaybackRate {
+ return when (this) {
+ THREE_QUARTERS_TIME -> THREE_QUARTERS_TIME
+ NORMAL_TIME -> THREE_QUARTERS_TIME
+ ONE_AND_A_QUARTER_TIME -> NORMAL_TIME
+ ONE_AND_A_HALF_TIME -> ONE_AND_A_QUARTER_TIME
+ DOUBLE_TIME -> ONE_AND_A_HALF_TIME
+ }
+ }
+
+ /**
+ * @return The speed above this speed (or the current speed if there is no higher speed)
+ */
+
+ fun increase(): PlayerPlaybackRate {
+ return when (this) {
+ THREE_QUARTERS_TIME -> NORMAL_TIME
+ NORMAL_TIME -> ONE_AND_A_QUARTER_TIME
+ ONE_AND_A_QUARTER_TIME -> ONE_AND_A_HALF_TIME
+ ONE_AND_A_HALF_TIME -> DOUBLE_TIME
+ DOUBLE_TIME -> DOUBLE_TIME
+ }
+ }
+}
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_backward.png b/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_backward.png
new file mode 100644
index 000000000..cde9c069e
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_backward.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_forward.png b/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_forward.png
new file mode 100644
index 000000000..43ec7126f
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/circle_arrow_forward.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/list.png b/simplified-viewer-audiobook/src/main/res/drawable/list.png
new file mode 100644
index 000000000..7a45fe4aa
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/list.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/pause_icon.png b/simplified-viewer-audiobook/src/main/res/drawable/pause_icon.png
new file mode 100644
index 000000000..c0e360f4a
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/pause_icon.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/play_icon.png b/simplified-viewer-audiobook/src/main/res/drawable/play_icon.png
new file mode 100644
index 000000000..025da7659
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/play_icon.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/sleep_icon.png b/simplified-viewer-audiobook/src/main/res/drawable/sleep_icon.png
new file mode 100644
index 000000000..393352b85
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/sleep_icon.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/drawable/speed.png b/simplified-viewer-audiobook/src/main/res/drawable/speed.png
new file mode 100644
index 000000000..dd4f0b410
Binary files /dev/null and b/simplified-viewer-audiobook/src/main/res/drawable/speed.png differ
diff --git a/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_base.xml b/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_base.xml
deleted file mode 100644
index 8854a0975..000000000
--- a/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_base.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_loading.xml b/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_loading.xml
deleted file mode 100644
index 9d29881e5..000000000
--- a/simplified-viewer-audiobook/src/main/res/layout/audio_book_player_loading.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
diff --git a/simplified-viewer-audiobook/src/main/res/values/strings.xml b/simplified-viewer-audiobook/src/main/res/values/strings.xml
index 164564952..31eab4956 100644
--- a/simplified-viewer-audiobook/src/main/res/values/strings.xml
+++ b/simplified-viewer-audiobook/src/main/res/values/strings.xml
@@ -8,4 +8,5 @@
Unable to initialize audio engine
Would you like to return it?
Your Audiobook Has Finished
+ Chapter %1$d
\ No newline at end of file
diff --git a/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/ExpiringResource.kt b/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/ExpiringResource.kt
new file mode 100644
index 000000000..764ef79a7
--- /dev/null
+++ b/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/ExpiringResource.kt
@@ -0,0 +1,40 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import org.readium.r2.shared.fetcher.Resource
+import org.readium.r2.shared.fetcher.ResourceTry
+import org.readium.r2.shared.publication.Link
+import org.readium.r2.shared.util.Try
+
+class ExpiringResource(
+ val link: Link,
+ private val content: ByteArray,
+ private val validCalls: Int
+) : Resource {
+
+ private var callCount = 0
+
+ override suspend fun link(): Link = link
+
+ override suspend fun length(): ResourceTry {
+ callCount++
+
+ return if (callCount > validCalls) {
+ Try.failure(Resource.Exception.NotFound())
+ } else {
+ Try.success(content.size.toLong())
+ }
+ }
+
+
+ override suspend fun read(range: LongRange?): ResourceTry {
+ callCount++
+
+ return if (callCount > validCalls) {
+ Try.failure(Resource.Exception.NotFound())
+ } else {
+ Try.success(content)
+ }
+ }
+
+ override suspend fun close() {}
+}
diff --git a/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResourceTest.kt b/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResourceTest.kt
new file mode 100644
index 000000000..e2c2b6507
--- /dev/null
+++ b/simplified-viewer-audiobook/src/test/java/org/nypl/simplified/viewer/audiobook/protection/UpdateManifestResourceTest.kt
@@ -0,0 +1,113 @@
+package org.nypl.simplified.viewer.audiobook.protection
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.readium.r2.shared.fetcher.FailureResource
+import org.readium.r2.shared.fetcher.Fetcher
+import org.readium.r2.shared.fetcher.Resource
+import org.readium.r2.shared.publication.Link
+import org.readium.r2.shared.publication.LocalizedString
+import org.readium.r2.shared.publication.Manifest
+import org.readium.r2.shared.publication.Metadata
+import org.readium.r2.shared.util.Try
+
+class UpdateManifestResourceTest {
+
+ private var manifestCallCount: Int = 0
+
+ @BeforeEach
+ fun testSetup() {
+ manifestCallCount = 0
+ }
+
+ @AfterEach
+ fun tearDown() {
+ }
+
+ @Test
+ fun `expired links are dealt with correctly`() {
+ val metadata = Metadata(
+ localizedTitle = LocalizedString("titleValue")
+ )
+
+ val originalManifest = Manifest(
+ metadata = metadata,
+ readingOrder = UpdateManifestFetcher.adaptReadingOrder(
+ listOf(
+ Link("chapter1"),
+ Link("chapter2"),
+ Link("chapter3")
+ )
+ )
+ )
+
+ val newManifest = Manifest(
+ metadata = metadata,
+ readingOrder = UpdateManifestFetcher.adaptReadingOrder(
+ listOf(
+ Link("newLinkToChapter1"),
+ Link("newLinkToChapter2"),
+ Link("newLinkToChapter3")
+ )
+ )
+ )
+
+ val baseFetcher = object: Fetcher {
+
+ val resourceContent = "Good".toByteArray(Charsets.UTF_8)
+
+ val firstResource = ExpiringResource(
+ originalManifest.readingOrder.first(),
+ resourceContent,
+ 3
+ )
+
+ val secondResource = ExpiringResource(
+ newManifest.readingOrder.first(),
+ resourceContent,
+ 3
+ )
+
+ private fun failureResource(link: Link) =
+ FailureResource(link, Resource.Exception.NotFound())
+
+ override suspend fun links(): List =
+ originalManifest.readingOrder.subList(0, 1)
+
+ override fun get(link: Link): Resource =
+ when (link.href) {
+ firstResource.link.href -> firstResource
+ secondResource.link.href -> secondResource
+ else -> failureResource(link)
+ }
+
+ override suspend fun close() {}
+ }
+
+ val getManifest = {
+ manifestCallCount +=1
+
+ when (manifestCallCount) {
+ 1 -> Try.success(originalManifest)
+ 2 -> Try.success(newManifest)
+ else -> Try.Failure(Exception("Cannot get a fresh manifest"))
+ }
+ }
+
+ val updateResource = UpdateManifestResource(
+ index = 0,
+ fallbackLink = Link("chapter1"),
+ baseFetcher = baseFetcher,
+ getManifest = getManifest,
+ invalidateManifest = {}
+ )
+
+ for (i in 0 until 6) {
+ val response = runBlocking { updateResource.readAsString().getOrNull() }
+ Assert.assertEquals( "Good", response)
+ }
+ }
+}