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
6 changes: 6 additions & 0 deletions app/src/main/java/app/waveflow/data/remote/CatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,18 @@ interface CatalogApi {
* Le ticket ne porte pas le rendu : [rendering] s'ajoute à l'URL rendue, et
* le serveur le lit au moment de servir. L'original n'y ajoute rien — le
* serveur refuse un débit accompagnant `raw`.
*
* @param offsetMs l'instant du morceau où le flux doit commencer. Un
* transcodage en direct n'a pas de plages : c'est ainsi qu'on s'y déplace.
* Ignoré pour l'original, que le serveur refuse de décaler et qui se
* déplace par plages.
*/
suspend fun streamTicket(
serverUrl: String,
accessToken: String,
trackId: String,
rendering: StreamRendering,
offsetMs: Long = 0L,
): String
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ class CatalogRepository(
* Demandée au moment de lire, et non à la constitution de la file : une
* longue file dépasserait l'échéance avant d'atteindre ses derniers
* morceaux.
*
* @param offsetMs voir [CatalogApi.streamTicket].
*/
suspend fun streamUrl(trackId: String, rendering: StreamRendering): String =
authorized { url, token -> api.streamTicket(url, token, trackId, rendering) }
suspend fun streamUrl(trackId: String, rendering: StreamRendering, offsetMs: Long = 0L): String =
authorized { url, token -> api.streamTicket(url, token, trackId, rendering, offsetMs) }

/**
* Exécute [call] avec un jeton valide, en réessayant une fois sur refus.
Expand Down
11 changes: 8 additions & 3 deletions app/src/main/java/app/waveflow/data/remote/HttpCatalogApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class HttpCatalogApi(
accessToken: String,
trackId: String,
rendering: StreamRendering,
offsetMs: Long,
): String {
val ticket = http.post(
serverUrl = serverUrl,
Expand All @@ -106,17 +107,21 @@ class HttpCatalogApi(
accessToken = accessToken,
).decode<StreamTicketResponse>()

return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery())
return http.absoluteUrl(serverUrl, ticket.url, query = rendering.toQuery(offsetMs))
}

/** L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on lui donne un débit. */
private fun StreamRendering.toQuery(): Map<String, String> =
/**
* L'original ne dit rien : le serveur le sert par défaut, et refuse qu'on
* lui donne un débit ou un décalage.
*/
private fun StreamRendering.toQuery(offsetMs: Long): Map<String, String> =
if (isOriginal) {
emptyMap()
} else {
buildMap {
put("format", format)
bitrate?.let { put("bitrate", it.toString()) }
if (offsetMs > 0L) put("offset_ms", offsetMs.toString())
}
}

Expand Down
70 changes: 70 additions & 0 deletions app/src/main/java/app/waveflow/playback/LogicalTimeline.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package app.waveflow.playback

import androidx.media3.common.C
import androidx.media3.common.Timeline
import androidx.media3.common.util.Util
import androidx.media3.exoplayer.source.ForwardingTimeline

/**
* La timeline de l'ExoPlayer, telle qu'Android doit la voir.
*
* Enveloppée et non reconstruite : la navigation — suivant, précédent, ordre
* aléatoire — reste celle de l'ExoPlayer. Reposer la liste de lecture sous forme
* de liste fabriquerait une `PlaylistTimeline`, qui ignore l'ordre aléatoire.
*
* Trois corrections, sur les seules pistes transcodées :
*
* - l'identifiant d'une piste relancée est celui qu'elle avait avant (R5) ;
* - la durée est celle du catalogue quand le flux n'en dit rien, ou quand un
* segment joue et n'en connaît que le reste (R3) ;
* - la piste est déplaçable (R4), et son élément est le marqueur sans décalage.
*
* @param fenetres identifiant logique, par identifiant de fenêtre de l'ExoPlayer.
* @param periodes identifiant logique, par identifiant de période de l'ExoPlayer.
* @param decalages décalage du flux, par identifiant de fenêtre de l'ExoPlayer.
*/
internal class LogicalTimeline(
timeline: Timeline,
private val fenetres: Map<Any, Any>,
private val periodes: Map<Any, Any>,
private val decalages: Map<Any, Long>,
) : ForwardingTimeline(timeline) {

private val periodesBrutes: Map<Any, Any> = periodes.entries.associate { (brute, logique) -> logique to brute }

override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
super.getWindow(windowIndex, window, defaultPositionProjectionUs)
val brute = window.uid
fenetres[brute]?.let { window.uid = it }

val item = window.mediaItem
if (item.isRemoteTranscode()) {
window.mediaItem = item.withStreamOffset(0L)
window.isSeekable = true
val catalogueMs = item.mediaMetadata.durationMs
if (catalogueMs != null && (decalages.containsKey(brute) || window.durationUs == C.TIME_UNSET)) {
window.durationUs = Util.msToUs(catalogueMs)
}
}
return window
}

override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
super.getPeriod(periodIndex, period, setIds)
if (setIds) period.uid?.let { brute -> periodes[brute]?.let { period.uid = it } }

// Un flux progressif n'a qu'une période, qui couvre sa fenêtre : sa durée
// suit celle de la fenêtre corrigée, sans quoi la fin du morceau se lirait
// à la fin du segment.
val fenetre = getWindow(period.windowIndex, Window())
if (fenetre.mediaItem.isRemoteTranscode() && period.positionInWindowUs == 0L) {
period.durationUs = fenetre.durationUs
}
return period
}

override fun getIndexOfPeriod(uid: Any): Int = super.getIndexOfPeriod(periodesBrutes[uid] ?: uid)

override fun getUidOfPeriod(periodIndex: Int): Any =
super.getUidOfPeriod(periodIndex).let { periodes[it] ?: it }
}
51 changes: 44 additions & 7 deletions app/src/main/java/app/waveflow/playback/MediaItemMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ fun Song.toMediaItem(): MediaItem =
* contre une URL de diffusion au moment où le lecteur ouvre la piste. Frapper
* le serveur ici, à la construction de la file, périmerait les tickets des
* derniers morceaux avant qu'on ne les atteigne.
*
* La durée du catalogue suit la piste : un transcodage en direct n'en annonce
* aucune, et c'est elle que la timeline logique expose à sa place.
*/
fun RemoteSong.toMediaItem(): MediaItem =
MediaItem.Builder()
Expand All @@ -48,6 +51,7 @@ fun RemoteSong.toMediaItem(): MediaItem =
.setArtist(artist)
.setAlbumTitle(album)
.setArtworkUri(artworkUri)
.setDurationMs(durationMs.takeIf { it > 0L })
.build(),
)
.build()
Expand Down Expand Up @@ -135,32 +139,65 @@ internal const val DEFAULT_FORMAT = StreamRendering.FORMAT_ORIGINAL
* que la qualité ne se choisisse : le cache déjà constitué reste valable.
*
* Une piste locale est rendue telle quelle : elle ne passe pas par le serveur.
* Un décalage déjà posé est effacé : une piste qui entre dans la file part du
* début.
*/
internal fun MediaItem.withRendering(rendering: StreamRendering): MediaItem {
val uri = localConfiguration?.uri ?: return this
val trackId = trackIdOfRemoteUri(uri) ?: return this

val marqueur = uri.buildUpon().clearQuery().apply {
return buildUpon()
.setUri(uri.marqueur(rendering, offsetMs = 0L))
.setCustomCacheKey(cacheKeyOf(trackId, rendering.format, rendering.bitrate))
.build()
}

/**
* Pose sur une piste distante transcodée l'instant où son flux doit commencer.
*
* Le décalage voyage dans le marqueur, comme le rendu : [RemoteStreamResolver]
* le relit, et la chaîne de lecture aiguille un segment hors du cache d'après ce
* même marqueur. Aucun état partagé ne s'intercale entre les deux. Voir
* `docs/deplacement-dans-un-transcodage.md`, R10 et R11.
*
* La clé de cache ne change pas, et `0` rend exactement le marqueur que
* [withRendering] avait posé : la piste revenue au début retrouve son cache.
*
* Sans effet sur une piste locale, ni sur l'original : le serveur refuse un
* décalage sur `raw` (R8), et l'original se déplace par plages.
*/
internal fun MediaItem.withStreamOffset(offsetMs: Long): MediaItem {
val uri = localConfiguration?.uri ?: return this
trackIdOfRemoteUri(uri) ?: return this

return buildUpon().setUri(uri.marqueur(renderingOfRemoteUri(uri), offsetMs)).build()
}

/** Le marqueur de cette piste pour ce rendu, à partir de cet instant. */
private fun android.net.Uri.marqueur(rendering: StreamRendering, offsetMs: Long): android.net.Uri =
buildUpon().clearQuery().apply {
if (!rendering.isOriginal) {
appendQueryParameter(PARAM_FORMAT, rendering.format)
rendering.bitrate?.let { appendQueryParameter(PARAM_BITRATE, it.toString()) }
if (offsetMs > 0L) appendQueryParameter(PARAM_OFFSET, offsetMs.toString())
}
}.build()

return buildUpon()
.setUri(marqueur)
.setCustomCacheKey(cacheKeyOf(trackId, rendering.format, rendering.bitrate))
.build()
}

/** Le rendu que porte le marqueur d'une piste distante ; l'original s'il n'en dit rien. */
internal fun renderingOfRemoteUri(uri: android.net.Uri): StreamRendering {
val format = uri.getQueryParameter(PARAM_FORMAT) ?: return StreamRendering.ORIGINAL
return StreamRendering(format, uri.getQueryParameter(PARAM_BITRATE)?.toIntOrNull())
}

/** L'instant du morceau où commence le flux de ce marqueur ; `0` hors segment. */
internal fun streamOffsetOfRemoteUri(uri: android.net.Uri): Long {
if (trackIdOfRemoteUri(uri) == null) return 0L
return uri.getQueryParameter(PARAM_OFFSET)?.toLongOrNull()?.coerceAtLeast(0L) ?: 0L
}

private const val PARAM_FORMAT = "format"
private const val PARAM_BITRATE = "bitrate"
private const val PARAM_OFFSET = "offset_ms"

/** Identifiant de piste serveur, ou `null` si la piste est locale. */
internal fun trackIdOfRemoteUri(uri: android.net.Uri): String? =
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/app/waveflow/playback/PlaybackService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class PlaybackService : MediaLibraryService() {
RemoteStreamResolver(container.catalogRepository),
)

val player = ExoPlayer.Builder(this)
val exoPlayer = ExoPlayer.Builder(this)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
// Route audio "musique" + gestion du focus audio (pause si un appel
// arrive, etc.).
Expand All @@ -107,6 +107,12 @@ class PlaybackService : MediaLibraryService() {
.setHandleAudioBecomingNoisy(true)
.build()

// Tout ce qui suit parle à l'enveloppe, jamais à l'ExoPlayer : dans un
// morceau transcodé relancé à 2:13, lui compte depuis zéro, et la
// notification, la voiture, l'historique ou la boucle A-B s'y
// tromperaient. Voir `docs/deplacement-dans-un-transcodage.md`, R1.
val player: Player = TranscodeSeekingPlayer(exoPlayer)

// Sans ce chargeur, Media3 irait chercher les pochettes avec son propre
// client HTTP, qui ne porte pas le jeton de session : `/api/v2/artwork/`
// lui répondait 401 et la notification restait sans vignette.
Expand Down
14 changes: 12 additions & 2 deletions app/src/main/java/app/waveflow/playback/RemoteMediaCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ class RemoteMediaCache(context: Context) : PlaybackCache {
* - `DefaultDataSource` aiguille en amont selon le schéma : `content://` et
* `file://` partent vers les sources locales sans jamais toucher au cache ;
* - au-dessus du cache, [IncompleteTranscodeEviction] retire le début d'un
* transcodage quitté en route, que le serveur ne laisserait pas compléter.
* transcodage quitté en route, que le serveur ne laisserait pas compléter ;
* - au-dessus encore, [SegmentCacheBypass] envoie un segment droit au
* résolveur : ses octets ne sont pas ceux du morceau, et le cache les
* confondrait.
*/
fun dataSourceFactory(resolver: ResolvingDataSource.Resolver): DataSource.Factory {
val resolving = ResolvingDataSource.Factory(DefaultHttpDataSource.Factory(), resolver)
Expand All @@ -72,7 +75,14 @@ class RemoteMediaCache(context: Context) : PlaybackCache {
IncompleteTranscodeEviction(cached.createDataSource(), cache, cles)
}

return DefaultDataSource.Factory(appContext, sansDebutOrphelin)
val segmentsHorsCache = DataSource.Factory {
SegmentCacheBypass(
cached = sansDebutOrphelin.createDataSource(),
direct = resolving.createDataSource(),
)
}

return DefaultDataSource.Factory(appContext, segmentsHorsCache)
}

override val maxBytes: Long = MAX_BYTES
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import java.io.IOException
* clé de cache a été calculée depuis ce même marqueur, avant ce résolveur. Relire
* le réglage ici laisserait un changement s'intercaler entre les deux, et une
* version se rangerait sous le nom d'une autre. Voir [withRendering].
*
* Le décalage d'un segment se lit au même endroit, pour la même raison : c'est
* sur ce marqueur que la chaîne a décidé de ne pas passer par le cache. Voir
* [withStreamOffset].
*/
class RemoteStreamResolver(
private val catalogRepository: CatalogRepository,
Expand All @@ -32,9 +36,10 @@ class RemoteStreamResolver(
override fun resolveDataSpec(dataSpec: DataSpec): DataSpec {
val trackId = trackIdOfRemoteUri(dataSpec.uri) ?: return dataSpec
val rendering = renderingOfRemoteUri(dataSpec.uri)
val offsetMs = streamOffsetOfRemoteUri(dataSpec.uri)

val url = try {
runBlocking { catalogRepository.streamUrl(trackId, rendering) }
runBlocking { catalogRepository.streamUrl(trackId, rendering, offsetMs) }
} catch (error: Exception) {
// Media3 n'attend que des IOException ici : toute autre remonterait
// brute jusqu'au lecteur et ferait tomber le service au lieu de
Expand Down
54 changes: 54 additions & 0 deletions app/src/main/java/app/waveflow/playback/SegmentCacheBypass.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package app.waveflow.playback

import android.net.Uri
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener

/**
* Fait passer un segment à côté du cache.
*
* Un segment — un flux demandé à partir d'un instant, `offset_ms` — n'est pas le
* morceau : ses octets ne commencent pas là où commencent ceux du morceau. Or le
* cache est posé **avant** le résolveur. Un segment qui s'y présenterait se
* verrait servir le morceau déjà en cache, depuis 0:00, pendant que l'écran
* afficherait l'instant demandé ; ou il y rangerait son reste sous un nom qui
* n'est pas le sien. Il va donc droit au résolveur, ni lu ni écrit.
*
* L'aiguillage se décide à l'ouverture, sur le marqueur même que le résolveur
* relira : aucun état partagé ne s'intercale entre les deux. Voir
* `docs/deplacement-dans-un-transcodage.md`, R11.
*/
internal class SegmentCacheBypass(
private val cached: DataSource,
private val direct: DataSource,
) : DataSource {

private var ouverte: DataSource? = null

override fun addTransferListener(transferListener: TransferListener) {
cached.addTransferListener(transferListener)
direct.addTransferListener(transferListener)
}

override fun open(dataSpec: DataSpec): Long {
val source = if (streamOffsetOfRemoteUri(dataSpec.uri) > 0L) direct else cached
ouverte = source
return source.open(dataSpec)
}

override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
checkNotNull(ouverte) { "lecture d'une source fermée" }.read(buffer, offset, length)

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

override fun getResponseHeaders(): Map<String, List<String>> = ouverte?.responseHeaders.orEmpty()

override fun close() {
try {
ouverte?.close()
} finally {
ouverte = null
}
}
}
Loading