diff --git a/readium/navigator-media2/src/test/java/org/readium/navigator/media2/SmartSeekerTest.kt b/readium/navigator-media2/src/test/java/org/readium/navigator/media2/SmartSeekerTest.kt index ef6ac54d05..737e3d4676 100644 --- a/readium/navigator-media2/src/test/java/org/readium/navigator/media2/SmartSeekerTest.kt +++ b/readium/navigator-media2/src/test/java/org/readium/navigator/media2/SmartSeekerTest.kt @@ -3,6 +3,7 @@ package org.readium.navigator.media2 import org.junit.Assert.assertEquals import org.junit.Test import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) @@ -10,97 +11,97 @@ class SmartSeekerTest { private val playlist: List = listOf( 10, 20, 15, 800, 10, 230, 20, 10 - ).map { Duration.seconds(it) } + ).map { it.seconds } - private val forwardOffset = Duration.seconds(50) + private val forwardOffset = 50.seconds - private val backwardOffset = Duration.seconds(-50) + private val backwardOffset = -50.seconds @Test fun `seek forward within current item`() { val result = SmartSeeker.dispatchSeek( offset = forwardOffset, - currentPosition = Duration.seconds(200), + currentPosition = 200.seconds, currentIndex = 3, playlist ) - assertEquals(SmartSeeker.Result(3, Duration.seconds(250)), result) + assertEquals(SmartSeeker.Result(3, 250.seconds), result) } @Test fun `seek backward within current item`() { val result = SmartSeeker.dispatchSeek( offset = backwardOffset, - currentPosition = Duration.seconds(200), + currentPosition = 200.seconds, currentIndex = 3, playlist ) - assertEquals(SmartSeeker.Result(3, Duration.seconds(150)), result) + assertEquals(SmartSeeker.Result(3, 150.seconds), result) } @Test fun `seek forward across items`() { val result = SmartSeeker.dispatchSeek( offset = forwardOffset, - currentPosition = Duration.seconds(780), + currentPosition = 780.seconds, currentIndex = 3, playlist ) - assertEquals(SmartSeeker.Result(5, Duration.seconds(20)), result) + assertEquals(SmartSeeker.Result(5, 20.seconds), result) } @Test fun `seek backward across items`() { val result = SmartSeeker.dispatchSeek( offset = backwardOffset, - currentPosition = Duration.seconds(10), + currentPosition = 10.seconds, currentIndex = 3, playlist ) - assertEquals(SmartSeeker.Result(0, Duration.seconds(5)), result) + assertEquals(SmartSeeker.Result(0, 5.seconds), result) } @Test fun `positive offset too big within last item`() { val result = SmartSeeker.dispatchSeek( offset = forwardOffset, - currentPosition = Duration.seconds(5), + currentPosition = 5.seconds, currentIndex = 7, playlist ) - assertEquals(SmartSeeker.Result(7, Duration.seconds(10)), result) + assertEquals(SmartSeeker.Result(7, 10.seconds), result) } @Test fun `positive offset too big across items`() { val result = SmartSeeker.dispatchSeek( offset = forwardOffset, - currentPosition = Duration.seconds(220), + currentPosition = 220.seconds, currentIndex = 6, playlist ) - assertEquals(SmartSeeker.Result(7, Duration.seconds(10)), result) + assertEquals(SmartSeeker.Result(7, 10.seconds), result) } @Test fun `negative offset too small within first item`() { val result = SmartSeeker.dispatchSeek( offset = backwardOffset, - currentPosition = Duration.seconds(5), + currentPosition = 5.seconds, currentIndex = 0, playlist ) - assertEquals(SmartSeeker.Result(0, Duration.seconds(0)), result) + assertEquals(SmartSeeker.Result(0, 0.seconds), result) } @Test fun `negative offset too small across items`() { val result = SmartSeeker.dispatchSeek( offset = backwardOffset, - currentPosition = Duration.seconds(10), + currentPosition = 10.seconds, currentIndex = 2, playlist ) - assertEquals(SmartSeeker.Result(0, Duration.seconds(0)), result) + assertEquals(SmartSeeker.Result(0, 0.seconds), result) } } \ No newline at end of file diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Duration.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Duration.kt index 52ebba5269..c788f2aeae 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Duration.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Duration.kt @@ -8,9 +8,9 @@ package org.readium.r2.navigator.extensions import android.text.format.DateUtils import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.ExperimentalTime -import kotlin.time.seconds @ExperimentalTime internal fun List.sum(): Duration = diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Locator.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Locator.kt index 685f9ca663..c09a868a1b 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Locator.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/extensions/Locator.kt @@ -10,10 +10,10 @@ package org.readium.r2.navigator.extensions import org.readium.r2.shared.publication.Locator -import java.util.* +import java.util.Locale import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime -import kotlin.time.seconds // FIXME: This should be in r2-shared once this public API is specified. diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media/ExoMediaPlayer.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/media/ExoMediaPlayer.kt index c2f40044a9..17f67a2db1 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/media/ExoMediaPlayer.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/media/ExoMediaPlayer.kt @@ -18,7 +18,12 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat -import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator @@ -29,7 +34,9 @@ import com.google.android.exoplayer2.upstream.HttpDataSource import com.google.android.exoplayer2.upstream.cache.Cache import com.google.android.exoplayer2.upstream.cache.CacheDataSource import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.readium.r2.navigator.ExperimentalAudiobook @@ -38,10 +45,14 @@ import org.readium.r2.navigator.audio.PublicationDataSource import org.readium.r2.navigator.extensions.timeWithDuration import org.readium.r2.shared.extensions.asInstance import org.readium.r2.shared.fetcher.Resource -import org.readium.r2.shared.publication.* +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.PublicationId +import org.readium.r2.shared.publication.indexOfFirstWithHref import timber.log.Timber import java.net.UnknownHostException -import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime /** @@ -76,9 +87,9 @@ class ExoMediaPlayer( factory } - private val player: ExoPlayer = SimpleExoPlayer.Builder(context) - .setSeekBackIncrementMs(Duration.seconds(30).inWholeMilliseconds) - .setSeekForwardIncrementMs(Duration.seconds(30).inWholeMilliseconds) + private val player: ExoPlayer = ExoPlayer.Builder(context) + .setSeekBackIncrementMs(30.seconds.inWholeMilliseconds) + .setSeekForwardIncrementMs(30.seconds.inWholeMilliseconds) .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .setAudioAttributes(AudioAttributes.Builder() .setContentType(C.CONTENT_TYPE_MUSIC) @@ -139,12 +150,19 @@ class ExoMediaPlayer( } override fun onDestroy() { - cancel() + // We destroy the player asynchronously, as [onDestroy] might be called synchronously from + // [MediaSessionConnector.onStop]. In which case, resetting the [MediaSessionConnector.player] + // property crashes the app. + destroy.start() + } + + private var destroy = async(start = CoroutineStart.LAZY) { mediaSessionConnector.setPlayer(null) notificationManager.setPlayer(null) player.stop() player.clearMediaItems() player.release() + cancel() } private fun prepareTracklist() { @@ -158,14 +176,14 @@ class ExoMediaPlayer( val readingOrder = publication.readingOrder val index = readingOrder.indexOfFirstWithHref(locator.href) ?: 0 - val duration = readingOrder[index].duration?.let { Duration.seconds(it) } + val duration = readingOrder[index].duration?.seconds val time = locator.locations.timeWithDuration(duration) player.seekTo(index, time?.inWholeMilliseconds ?: 0) } private inner class PlayerListener : Player.Listener { - override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_IDLE) { listener?.onPlayerStopped() } diff --git a/readium/navigator/src/main/java/org/readium/r2/navigator/media/MediaSessionNavigator.kt b/readium/navigator/src/main/java/org/readium/r2/navigator/media/MediaSessionNavigator.kt index 87ca2c5f42..f82cd9dc84 100644 --- a/readium/navigator/src/main/java/org/readium/r2/navigator/media/MediaSessionNavigator.kt +++ b/readium/navigator/src/main/java/org/readium/r2/navigator/media/MediaSessionNavigator.kt @@ -12,15 +12,40 @@ import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaControllerCompat.TransportControls import android.support.v4.media.session.PlaybackStateCompat -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.readium.r2.navigator.ExperimentalAudiobook import org.readium.r2.navigator.MediaNavigator import org.readium.r2.navigator.extensions.sum -import org.readium.r2.navigator.media.extensions.* -import org.readium.r2.shared.publication.* +import org.readium.r2.navigator.media.extensions.elapsedPosition +import org.readium.r2.navigator.media.extensions.id +import org.readium.r2.navigator.media.extensions.isPlaying +import org.readium.r2.navigator.media.extensions.publicationId +import org.readium.r2.navigator.media.extensions.resourceHref +import org.readium.r2.navigator.media.extensions.toPlaybackState +import org.readium.r2.shared.publication.Link +import org.readium.r2.shared.publication.Locator +import org.readium.r2.shared.publication.Publication +import org.readium.r2.shared.publication.PublicationId +import org.readium.r2.shared.publication.indexOfFirstWithHref import timber.log.Timber import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.ExperimentalTime /** @@ -29,9 +54,9 @@ import kotlin.time.ExperimentalTime private const val playbackPositionRefreshRate: Double = 2.0 // Hz @OptIn(ExperimentalTime::class) -private val skipForwardInterval: Duration = Duration.seconds(30) +private val skipForwardInterval: Duration = 30.seconds @OptIn(ExperimentalTime::class) -private val skipBackwardInterval: Duration = Duration.seconds(30) +private val skipBackwardInterval: Duration = 30.seconds /** * An implementation of [MediaNavigator] using an Android's MediaSession compatible media player. @@ -70,19 +95,19 @@ class MediaSessionNavigator( publication.readingOrder.map { link -> link.duration ?.takeIf { it > 0 } - ?.let { Duration.seconds(it) } + ?.seconds } /** * Total duration of the publication. */ private val totalDuration: Duration? = - durations.sum().takeIf { it > Duration.seconds(0) } + durations.sum().takeIf { it > 0.seconds } private val mediaMetadata = MutableStateFlow(null) private val playbackState = MutableStateFlow(null) - private val playbackPosition = MutableStateFlow(Duration.seconds(0)) + private val playbackPosition = MutableStateFlow(0.seconds) init { controller.registerCallback(MediaControllerCallback()) @@ -104,12 +129,12 @@ class MediaSessionNavigator( positionBroadcastJob = launch { var state = controller.playbackState while (isActive && state.state == PlaybackStateCompat.STATE_PLAYING) { - val newPosition = Duration.milliseconds(state.elapsedPosition) + val newPosition = state.elapsedPosition.milliseconds if (playbackPosition.value != newPosition) { playbackPosition.value = newPosition } - delay(Duration.seconds((1.0 / playbackPositionRefreshRate))) + delay((1.0 / playbackPositionRefreshRate).seconds) state = controller.playbackState } } @@ -218,7 +243,7 @@ class MediaSessionNavigator( ) { metadata, state, positionMs -> // FIXME: Since upgrading to the latest flow version, there's a weird crash when combining a `Flow`, like `playbackPosition`. Mapping it seems to do the trick. // See https://github.com/Kotlin/kotlinx.coroutines/issues/2353 - val position = Duration.milliseconds(positionMs) + val position = positionMs.milliseconds val index = metadata.resourceHref?.let { publication.readingOrder.indexOfFirstWithHref(it) } if (index == null) { @@ -288,7 +313,7 @@ class MediaSessionNavigator( if (!isActive) return @Suppress("NAME_SHADOWING") - val position = position.coerceAtLeast(Duration.seconds(0)) + val position = position.coerceAtLeast(0.seconds) // We overwrite the current position to allow skipping successively several time without // having to wait for the playback position to actually update.