Skip to content

Commit

Permalink
Improvements on ExoPlayer audio navigator (#542)
Browse files Browse the repository at this point in the history
  • Loading branch information
qnga authored Jul 1, 2024
1 parent 8566246 commit dfdadbb
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 115 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.readium.adapter.exoplayer.audio

import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.Cache
import org.readium.r2.shared.DelicateReadiumApi
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.util.Url

/**
* Uses the given cache only for remote publications and URLs resolved against the
* publication base URL as cache keys.
*/
@OptIn(UnstableApi::class)
public class DefaultExoPlayerCacheProvider(
private val cache: Cache
) : ExoPlayerCacheProvider {

@kotlin.OptIn(DelicateReadiumApi::class)
override fun getCache(publication: Publication): Cache? =
cache.takeUnless { publication.baseUrl == null }

override fun computeKey(publication: Publication, url: Url): String =
(publication.baseUrl?.resolve(url) ?: url).normalize().toString()
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.readium.adapter.exoplayer.audio

import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.Cache
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.util.Url

/**
* To be implemented to provide ExoPlayer with caching ability.
*/
@OptIn(UnstableApi::class)
public interface ExoPlayerCacheProvider {

/**
* Returns the cache to use or null if caching is not necessary with the given publication.
*/
public fun getCache(publication: Publication): Cache?

/**
* Computes a unique cache key for the resource of [publication] at [url] . It can be an
* absolute URL or a mix of [url] with some publication identifier.
*/
public fun computeKey(publication: Publication, url: Url): String
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,32 @@ import androidx.media3.datasource.BaseDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import java.io.IOException
import kotlinx.coroutines.runBlocking
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.util.DebugError
import org.readium.r2.shared.util.data.ReadError
import org.readium.r2.shared.util.data.ReadException
import org.readium.r2.shared.util.getOrThrow
import org.readium.r2.shared.util.resource.Resource
import org.readium.r2.shared.util.resource.buffered
import org.readium.r2.shared.util.toUrl

internal sealed class ExoPlayerDataSourceException(message: String, cause: Throwable?) : IOException(
message,
cause
) {
class NotOpened(message: String) : ExoPlayerDataSourceException(message, null)
class NotFound(message: String) : ExoPlayerDataSourceException(message, null)
class ReadFailed(uri: Uri, offset: Int, readLength: Int, cause: Throwable) : ExoPlayerDataSourceException(
"Failed to read $readLength bytes of URI $uri at offset $offset.",
cause
)
}
import timber.log.Timber

/**
* An ExoPlayer's [DataSource] which retrieves resources from a [Publication].
*/
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
public class PublicationExoPlayerDataSource internal constructor(
internal class ExoPlayerDataSource internal constructor(
private val publication: Publication
) : BaseDataSource(/* isNetwork = */ true) {

public class Factory(
class Factory(
private val publication: Publication,
private val transferListener: TransferListener? = null
) : DataSource.Factory {

override fun createDataSource(): DataSource =
PublicationExoPlayerDataSource(publication).apply {
ExoPlayerDataSource(publication).apply {
if (transferListener != null) {
addTransferListener(transferListener)
}
Expand All @@ -58,36 +48,49 @@ public class PublicationExoPlayerDataSource internal constructor(
private data class OpenedResource(
val resource: Resource,
val uri: Uri,
var position: Long
var position: Long,
var remaining: Long
)

private var openedResource: OpenedResource? = null

override fun open(dataSpec: DataSpec): Long {
val resource = dataSpec.uri.toUrl()
val link = dataSpec.uri.toUrl()
?.let { publication.linkWithHref(it) }
?.let { publication.get(it) }
// Significantly improves performances, in particular with deflated ZIP entries.
?.buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
?: throw ExoPlayerDataSourceException.NotFound(
?: throw IllegalStateException(
"Can't find a [Link] for URI: ${dataSpec.uri}. Make sure you only request resources declared in the manifest."
)

openedResource = OpenedResource(
resource = resource,
uri = dataSpec.uri,
position = dataSpec.position
)
val resource = publication.get(link)
// Significantly improves performances, in particular with deflated ZIP entries.
?.buffered(resourceLength = cachedLengths[dataSpec.uri.toString()])
?: throw ReadException(
ReadError.Decoding(
DebugError(
"Can't find an entry for URI: ${dataSpec.uri}. Publication looks invalid."
)
)
)

val bytesToRead =
if (dataSpec.length != LENGTH_UNSET.toLong()) {
dataSpec.length
} else {
val contentLength = contentLengthOf(dataSpec.uri, resource)
?: return dataSpec.length
contentLength - dataSpec.position
if (contentLength == null) {
LENGTH_UNSET.toLong()
} else {
contentLength - dataSpec.position
}
}

openedResource = OpenedResource(
resource = resource,
uri = dataSpec.uri,
position = dataSpec.position,
remaining = bytesToRead
)

return bytesToRead
}

Expand All @@ -109,16 +112,26 @@ public class PublicationExoPlayerDataSource internal constructor(
return 0
}

val openedResource = openedResource ?: throw ExoPlayerDataSourceException.NotOpened(
val openedResource = openedResource ?: throw IllegalStateException(
"No opened resource to read from. Did you call open()?"
)

if (openedResource.remaining == 0L) {
return RESULT_END_OF_INPUT
}

val bytesToRead = length.toLong().coerceAtMost(openedResource.remaining)

try {
val data = runBlocking {
openedResource.resource
.read(range = openedResource.position until (openedResource.position + length))
.mapFailure { ReadException(it) }
.getOrThrow()
.read(
range = openedResource.position until (openedResource.position + bytesToRead)
)
.mapFailure {
Timber.v("Failed to read $length bytes of URI $uri at offset $offset.")
ReadException(it)
}.getOrThrow()
}

if (data.isEmpty()) {
Expand All @@ -133,33 +146,20 @@ public class PublicationExoPlayerDataSource internal constructor(
)

openedResource.position += data.count()
openedResource.remaining -= data.count()
return data.count()
} catch (e: Exception) {
if (e is InterruptedException) {
return 0
}
throw ExoPlayerDataSourceException.ReadFailed(
uri = openedResource.uri,
offset = offset,
readLength = length,
cause = e
)
throw e
}
}

override fun getUri(): Uri? = openedResource?.uri

override fun close() {
openedResource?.run {
try {
runBlocking { resource.close() }
} catch (e: Exception) {
if (e !is InterruptedException) {
throw e
}
} finally {
openedResource = null
}
}
openedResource?.resource?.close()
openedResource = null
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,39 @@
package org.readium.adapter.exoplayer.audio

import android.app.Application
import androidx.media3.common.*
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.readium.navigator.media.audio.AudioEngine
import org.readium.r2.shared.ExperimentalReadiumApi
import org.readium.r2.shared.InternalReadiumApi
import org.readium.r2.shared.extensions.findInstance
import org.readium.r2.shared.util.ThrowableError
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.data.ReadError
import org.readium.r2.shared.util.data.ReadException
import org.readium.r2.shared.util.toUri
import org.readium.r2.shared.util.units.Hz
import org.readium.r2.shared.util.units.hz
Expand Down Expand Up @@ -164,7 +182,17 @@ public class ExoPlayerEngine private constructor(
}
}

public data class Error(val error: ExoPlaybackException) : AudioEngine.Error
public sealed class Error(
override val message: String,
override val cause: org.readium.r2.shared.util.Error?
) : AudioEngine.Error {

public data class Engine(override val cause: ThrowableError<ExoPlaybackException>) :
Error("An error occurred in the ExoPlayer engine.", cause)

public data class Source(override val cause: ReadError) :
Error("An error occurred while trying to read publication content.", cause)
}

private val coroutineScope: CoroutineScope =
MainScope()
Expand Down Expand Up @@ -260,6 +288,22 @@ public class ExoPlayerEngine private constructor(
Player.STATE_READY -> AudioEngine.State.Ready
Player.STATE_BUFFERING -> AudioEngine.State.Buffering
Player.STATE_ENDED -> AudioEngine.State.Ended
else -> AudioEngine.State.Failure(Error(playerError!!))
else -> AudioEngine.State.Failure(playerError!!.toError())
}

@OptIn(InternalReadiumApi::class)
private fun ExoPlaybackException.toError(): Error {
val readError =
if (type == ExoPlaybackException.TYPE_SOURCE) {
sourceException.findInstance(ReadException::class.java)?.error
} else {
null
}

return if (readError == null) {
Error.Engine(ThrowableError(this))
} else {
Error.Source(readError)
}
}
}
Loading

0 comments on commit dfdadbb

Please sign in to comment.