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) + } + } +}