feat: Add DASH support and stream resolution with conflict-safe sync protection

- Add Media3 DASH dependency for adaptive streaming support
- Implement StreamResolver for URL resolution and MIME type detection
- Update AudioHandler to resolve streams before playback with proper MIME hints
- Add immediate buffering state feedback in UI during stream resolution
- Protect local pending changes from being overwritten during sync operations
- Add French comment clarifying sync conflict prevention logic
This commit is contained in:
Bruno Charest 2026-02-11 16:31:04 -05:00
parent 2b8134f5b7
commit 4bd1b5a6f3
5 changed files with 478 additions and 34 deletions

View File

@ -156,6 +156,7 @@ dependencies {
// Media3 (ExoPlayer + MediaSession for audio playback)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.ui)

View File

@ -410,6 +410,11 @@ class SyncManager @Inject constructor(
val existing = linkDao.getLinkById(dto.id!!)
val serverUpdatedAt = parseDate(dto.updated)
// Ne jamais écraser un lien avec des modifications locales en attente
if (existing != null && existing.syncStatus != SyncStatus.SYNCED) {
return@mapNotNull null
}
// Check if this link has been modified since last sync
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
// Link unchanged since last sync — skip

View File

@ -0,0 +1,404 @@
package com.shaarit.domain.audio
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
/**
* Résultat de la résolution d'un flux audio.
* @param streamUrl URL directe du flux audio à donner à ExoPlayer
* @param mimeType MIME type hint pour ExoPlayer (null = auto-detect)
*/
data class ResolvedStream(
val streamUrl: String,
val mimeType: String? = null
)
/**
* Résout n'importe quelle URL audio en une URL de flux directe jouable par ExoPlayer.
*
* Formats supportés :
* - Direct : .mp3, .aac, .ogg, .wav, .flac, .opus
* - Playlist : .m3u, .m3u8 (playlist simple), .pls, .asx
* - Adaptatif : .m3u8 (HLS), .mpd (DASH)
* - Metadata : RSS/XML (podcasts extraction <enclosure>)
* - Sans extension : sniffing HTTP Content-Type via HEAD
*/
@Singleton
class StreamResolver @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "StreamResolver"
// MIME types connus pour les flux audio directs
private val DIRECT_AUDIO_MIMES = setOf(
"audio/mpeg", "audio/mp3",
"audio/aac", "audio/aacp", "audio/x-aac",
"audio/ogg", "audio/vorbis", "audio/opus",
"audio/wav", "audio/x-wav",
"audio/flac", "audio/x-flac",
"audio/mp4", "audio/x-m4a",
"audio/x-ms-wma"
)
// MIME types pour les playlists
private val PLAYLIST_MIMES = setOf(
"audio/x-mpegurl", "application/vnd.apple.mpegurl", // m3u / m3u8
"audio/x-scpls", "application/pls+xml", // pls
"video/x-ms-asf", "application/x-mms-framed" // asx
)
// MIME types pour HLS
private val HLS_MIMES = setOf(
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl"
)
// Extensions → MIME mapping
private val EXTENSION_MIME_MAP = mapOf(
"mp3" to "audio/mpeg",
"aac" to "audio/aac",
"ogg" to "audio/ogg",
"oga" to "audio/ogg",
"opus" to "audio/opus",
"wav" to "audio/wav",
"flac" to "audio/flac",
"m4a" to "audio/mp4",
"wma" to "audio/x-ms-wma",
"m3u" to "audio/x-mpegurl",
"m3u8" to "application/vnd.apple.mpegurl",
"pls" to "audio/x-scpls",
"asx" to "video/x-ms-asf",
"mpd" to "application/dash+xml"
)
// Content-Type pour DASH
private const val MIME_DASH = "application/dash+xml"
// Content-Type pour HLS
private const val MIME_HLS = "application/vnd.apple.mpegurl"
}
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
/**
* Résout une URL en flux audio jouable.
* Doit être appelée depuis une coroutine (suspend).
*/
suspend fun resolve(url: String): ResolvedStream = withContext(Dispatchers.IO) {
try {
val ext = extractExtension(url)
Log.d(TAG, "Resolving URL: $url (ext=$ext)")
when {
// ---- Playlist .pls ----
ext == "pls" -> resolvePls(url)
// ---- Playlist .m3u (non-HLS) ----
ext == "m3u" -> resolveM3u(url)
// ---- HLS .m3u8 ----
ext == "m3u8" -> resolveHls(url)
// ---- DASH .mpd ----
ext == "mpd" -> ResolvedStream(url, MIME_DASH)
// ---- ASX (Windows Media) ----
ext == "asx" -> resolveAsx(url)
// ---- RSS / XML (podcast feeds) ----
ext == "xml" || ext == "rss" || ext == "atom" -> resolveRssFeed(url)
// ---- Direct audio avec extension connue ----
ext != null && EXTENSION_MIME_MAP.containsKey(ext) ->
ResolvedStream(url, EXTENSION_MIME_MAP[ext])
// ---- Pas d'extension → sniffing HTTP ----
else -> resolveByContentType(url)
}
} catch (e: Exception) {
Log.w(TAG, "Resolution failed for $url, falling back to raw URL", e)
// Fallback : laisser ExoPlayer tenter avec l'URL brute
ResolvedStream(url, null)
}
}
// ========================= Parsers de Playlists =========================
/**
* Parse un fichier .pls (format INI) et extrait la première URL File1=
*/
private fun resolvePls(url: String): ResolvedStream {
val body = fetchBody(url)
// Cherche File1=, File2=, etc. — on prend le premier
val fileRegex = Regex("""^File\d+=(.+)$""", RegexOption.MULTILINE)
val match = fileRegex.find(body)
val streamUrl = match?.groupValues?.get(1)?.trim()
?: throw IllegalStateException("No File entry found in PLS: $url")
Log.d(TAG, "PLS resolved: $streamUrl")
return resolveIfNeeded(streamUrl)
}
/**
* Parse un fichier .m3u (playlist simple, pas HLS).
* Extrait la première ligne non-commentaire non-vide.
*/
private fun resolveM3u(url: String): ResolvedStream {
val body = fetchBody(url)
val lines = body.lines()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
// Si le contenu ressemble à du HLS (contient #EXT-X-), traiter comme HLS
if (body.contains("#EXT-X-") || body.contains("#EXTINF")) {
// Pourrait être un m3u8 mal nommé en .m3u
return ResolvedStream(url, MIME_HLS)
}
val streamUrl = lines.firstOrNull()
?: throw IllegalStateException("No stream URL found in M3U: $url")
Log.d(TAG, "M3U resolved: $streamUrl")
return resolveIfNeeded(streamUrl)
}
/**
* HLS (.m3u8) : on laisse ExoPlayer gérer nativement, on hint juste le MIME.
*/
private fun resolveHls(url: String): ResolvedStream {
return ResolvedStream(url, MIME_HLS)
}
/**
* Parse un fichier .asx (XML Windows Media) et extrait le premier <ref href="..."/>
*/
private fun resolveAsx(url: String): ResolvedStream {
val body = fetchBody(url)
// ASX est du XML simple. On cherche <ref href="..."/>
val refRegex = Regex("""<ref\s+href\s*=\s*"([^"]+)"""", RegexOption.IGNORE_CASE)
val match = refRegex.find(body)
val streamUrl = match?.groupValues?.get(1)?.trim()
?: throw IllegalStateException("No <ref> found in ASX: $url")
Log.d(TAG, "ASX resolved: $streamUrl")
return resolveIfNeeded(streamUrl)
}
/**
* Parse un flux RSS/XML (podcast) et extrait l'URL <enclosure> du premier épisode.
*/
private fun resolveRssFeed(url: String): ResolvedStream {
val body = fetchBody(url)
// Cherche <enclosure url="..."/> dans le premier <item>
val enclosureRegex = Regex(
"""<enclosure[^>]+url\s*=\s*"([^"]+)"[^>]*/?>""",
RegexOption.IGNORE_CASE
)
val match = enclosureRegex.find(body)
if (match != null) {
val streamUrl = match.groupValues[1].trim()
Log.d(TAG, "RSS enclosure resolved: $streamUrl")
return resolveIfNeeded(streamUrl)
}
// Fallback: cherche <url> dans un <media:content>
val mediaRegex = Regex(
"""<media:content[^>]+url\s*=\s*"([^"]+)"[^>]*/?>""",
RegexOption.IGNORE_CASE
)
val mediaMatch = mediaRegex.find(body)
if (mediaMatch != null) {
val streamUrl = mediaMatch.groupValues[1].trim()
Log.d(TAG, "RSS media:content resolved: $streamUrl")
return resolveIfNeeded(streamUrl)
}
throw IllegalStateException("No audio enclosure found in RSS feed: $url")
}
// ========================= Sniffing Content-Type =========================
/**
* Pour les URLs sans extension connue (ex: http://stream.rcs.revma.com/07fumytrgzzuv),
* fait un HTTP HEAD (puis GET si HEAD échoue) pour déterminer le Content-Type.
*/
private fun resolveByContentType(url: String): ResolvedStream {
val contentType = sniffContentType(url)
Log.d(TAG, "Sniffed Content-Type for $url: $contentType")
return when {
contentType == null -> {
// Impossible de déterminer — on tente en progressive audio/mpeg (le plus courant)
Log.d(TAG, "No Content-Type detected, assuming progressive audio/mpeg")
ResolvedStream(url, "audio/mpeg")
}
// Stream audio direct (Icecast/Shoutcast renvoient souvent audio/mpeg)
DIRECT_AUDIO_MIMES.any { contentType.startsWith(it) } ->
ResolvedStream(url, contentType.substringBefore(";").trim())
// HLS
HLS_MIMES.any { contentType.startsWith(it) } ->
ResolvedStream(url, MIME_HLS)
// DASH
contentType.startsWith(MIME_DASH) ->
ResolvedStream(url, MIME_DASH)
// Playlist .pls servie avec un mauvais MIME
contentType.contains("scpls") || contentType.contains("pls") ->
resolvePls(url)
// RSS / XML / Atom feeds
contentType.contains("xml") || contentType.contains("rss") || contentType.contains("atom") ->
resolveRssFeed(url)
// application/octet-stream — souvent des streams audio bruts
contentType.startsWith("application/octet-stream") -> {
Log.d(TAG, "octet-stream detected, assuming progressive audio/mpeg")
ResolvedStream(url, "audio/mpeg")
}
// binary/octet-stream — variante Icecast
contentType.startsWith("binary/octet-stream") -> {
Log.d(TAG, "binary/octet-stream detected, assuming progressive audio/mpeg")
ResolvedStream(url, "audio/mpeg")
}
// text/html — peut être une page de redirection, on tente quand même
contentType.startsWith("text/html") -> {
Log.w(TAG, "Got text/html for $url — trying as progressive stream")
ResolvedStream(url, null)
}
else -> {
Log.d(TAG, "Unknown Content-Type '$contentType', passing to ExoPlayer as-is")
ResolvedStream(url, contentType.substringBefore(";").trim())
}
}
}
// ========================= HTTP Helpers =========================
/**
* Fait un HTTP HEAD (puis GET si HEAD retourne 405) pour récupérer le Content-Type.
* Suit les redirections (Location header, OkHttp followRedirects).
*/
private fun sniffContentType(url: String): String? {
// Essai HEAD d'abord (plus léger)
try {
val headRequest = Request.Builder()
.url(url)
.head()
.header("User-Agent", "ShaarIt/1.0 (Android)")
.header("Accept", "*/*")
.build()
httpClient.newCall(headRequest).execute().use { response ->
if (response.isSuccessful) {
val ct = response.header("Content-Type")
// Vérifier aussi l'icy-* headers (Icecast/Shoutcast)
val icyName = response.header("icy-name")
if (ct != null) return ct.lowercase()
if (icyName != null) return "audio/mpeg" // Icecast sans Content-Type
}
}
} catch (e: Exception) {
Log.d(TAG, "HEAD failed for $url: ${e.message}")
}
// Fallback: GET partiel (Range: bytes=0-0 ou juste les headers)
try {
val getRequest = Request.Builder()
.url(url)
.header("User-Agent", "ShaarIt/1.0 (Android)")
.header("Accept", "*/*")
.header("Range", "bytes=0-1024")
.header("Icy-MetaData", "1") // Active les métadonnées Icecast
.build()
httpClient.newCall(getRequest).execute().use { response ->
val ct = response.header("Content-Type")
val icyName = response.header("icy-name")
val icyGenre = response.header("icy-genre")
// Icecast/Shoutcast stream détecté par icy-* headers
if (icyName != null || icyGenre != null) {
return ct?.lowercase() ?: "audio/mpeg"
}
return ct?.lowercase()
}
} catch (e: Exception) {
Log.w(TAG, "GET sniff also failed for $url: ${e.message}")
}
return null
}
/**
* Télécharge le body texte d'une URL (pour parser playlists / RSS).
*/
private fun fetchBody(url: String): String {
val request = Request.Builder()
.url(url)
.header("User-Agent", "ShaarIt/1.0 (Android)")
.header("Accept", "*/*")
.build()
httpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("HTTP ${response.code} for $url")
}
return response.body?.string()
?: throw IllegalStateException("Empty body for $url")
}
}
// ========================= Utilitaires =========================
/**
* Extrait l'extension d'une URL (sans query params).
* Retourne null si pas d'extension ou extension inconnue.
*/
private fun extractExtension(url: String): String? {
val path = try {
android.net.Uri.parse(url).path ?: return null
} catch (e: Exception) {
return null
}
val lastSegment = path.substringAfterLast("/")
if (!lastSegment.contains(".")) return null
val ext = lastSegment.substringAfterLast(".").lowercase()
return ext.takeIf { it.length in 1..5 }
}
/**
* Si l'URL résolue depuis une playlist a elle-même besoin de résolution
* (ex: un PLS pointe vers un autre .m3u8), on résout récursivement (1 niveau).
*/
private fun resolveIfNeeded(streamUrl: String): ResolvedStream {
val ext = extractExtension(streamUrl)
return when (ext) {
"pls" -> resolvePls(streamUrl)
"m3u" -> resolveM3u(streamUrl)
"m3u8" -> ResolvedStream(streamUrl, MIME_HLS)
"mpd" -> ResolvedStream(streamUrl, MIME_DASH)
"asx" -> resolveAsx(streamUrl)
else -> {
val mime = EXTENSION_MIME_MAP[ext]
ResolvedStream(streamUrl, mime)
}
}
}
}

View File

@ -2,13 +2,16 @@ package com.shaarit.presentation.audio
import android.content.ComponentName
import android.content.Context
import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import com.shaarit.domain.audio.StreamResolver
import com.shaarit.domain.model.AudioContentType
import com.shaarit.domain.model.AudioMedia
import com.shaarit.domain.model.PlayerState
@ -34,7 +37,8 @@ import javax.inject.Singleton
*/
@Singleton
class AudioHandler @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
private val streamResolver: StreamResolver
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@ -113,46 +117,75 @@ class AudioHandler @Inject constructor(
// ========================= Contrôles Publics =========================
fun play(media: AudioMedia) {
ensureConnected {
val mediaItem = MediaItem.Builder()
.setMediaId(media.id.toString())
.setUri(media.url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(media.title)
.setArtist(media.artist ?: media.contentType.name)
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
.build()
)
.build()
// Afficher immédiatement l'état "buffering" dans l'UI
_playerState.update { it.copy(currentMedia = media, isBuffering = true) }
_playerState.update { it.copy(currentMedia = media) }
it.setMediaItem(mediaItem)
it.prepare()
it.play()
scope.launch {
try {
val resolved = streamResolver.resolve(media.url)
Log.d("AudioHandler", "Resolved: ${resolved.streamUrl} (mime=${resolved.mimeType})")
ensureConnected { mc ->
val mediaItemBuilder = MediaItem.Builder()
.setMediaId(media.id.toString())
.setUri(resolved.streamUrl)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(media.title)
.setArtist(media.artist ?: media.contentType.name)
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
.build()
)
// Hint MIME type pour que ExoPlayer choisisse le bon extracteur
if (resolved.mimeType != null) {
mediaItemBuilder.setMimeType(resolved.mimeType)
}
val mediaItem = mediaItemBuilder.build()
mc.setMediaItem(mediaItem)
mc.prepare()
mc.play()
}
} catch (e: Exception) {
Log.e("AudioHandler", "Failed to resolve/play: ${media.url}", e)
_playerState.update { it.copy(isBuffering = false) }
}
}
}
fun playAll(mediaList: List<AudioMedia>, startIndex: Int = 0) {
if (mediaList.isEmpty()) return
ensureConnected { mc ->
val items = mediaList.map { media ->
MediaItem.Builder()
.setMediaId(media.id.toString())
.setUri(media.url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(media.title)
.setArtist(media.artist ?: media.contentType.name)
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
.build()
)
.build()
_playerState.update { it.copy(currentMedia = mediaList[startIndex], isBuffering = true) }
scope.launch {
try {
val items = mediaList.map { media ->
val resolved = streamResolver.resolve(media.url)
val builder = MediaItem.Builder()
.setMediaId(media.id.toString())
.setUri(resolved.streamUrl)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(media.title)
.setArtist(media.artist ?: media.contentType.name)
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
.build()
)
if (resolved.mimeType != null) {
builder.setMimeType(resolved.mimeType)
}
builder.build()
}
ensureConnected { mc ->
mc.setMediaItems(items, startIndex, 0L)
mc.prepare()
mc.play()
}
} catch (e: Exception) {
Log.e("AudioHandler", "Failed to resolve/play playlist", e)
_playerState.update { it.copy(isBuffering = false) }
}
mc.setMediaItems(items, startIndex, 0L)
mc.prepare()
mc.play()
_playerState.update { it.copy(currentMedia = mediaList[startIndex]) }
}
}

View File

@ -91,6 +91,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version
# Media3 (ExoPlayer + MediaSession)
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }