Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,104 +3,105 @@ 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)
class SmartSeekerTest {

private val playlist: List<Duration> = 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>.sum(): Duration =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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<MediaMetadataCompat?>(null)
private val playbackState = MutableStateFlow<PlaybackStateCompat?>(null)
private val playbackPosition = MutableStateFlow(Duration.seconds(0))
private val playbackPosition = MutableStateFlow(0.seconds)

init {
controller.registerCallback(MediaControllerCallback())
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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<Duration>`, 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) {
Expand Down Expand Up @@ -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.
Expand Down