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:
parent
2b8134f5b7
commit
4bd1b5a6f3
@ -156,6 +156,7 @@ dependencies {
|
|||||||
// Media3 (ExoPlayer + MediaSession for audio playback)
|
// Media3 (ExoPlayer + MediaSession for audio playback)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.exoplayer.hls)
|
implementation(libs.androidx.media3.exoplayer.hls)
|
||||||
|
implementation(libs.androidx.media3.exoplayer.dash)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
|
|
||||||
|
|||||||
@ -410,6 +410,11 @@ class SyncManager @Inject constructor(
|
|||||||
val existing = linkDao.getLinkById(dto.id!!)
|
val existing = linkDao.getLinkById(dto.id!!)
|
||||||
val serverUpdatedAt = parseDate(dto.updated)
|
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
|
// Check if this link has been modified since last sync
|
||||||
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
|
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
|
||||||
// Link unchanged since last sync — skip
|
// Link unchanged since last sync — skip
|
||||||
|
|||||||
404
app/src/main/java/com/shaarit/domain/audio/StreamResolver.kt
Normal file
404
app/src/main/java/com/shaarit/domain/audio/StreamResolver.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,13 +2,16 @@ package com.shaarit.presentation.audio
|
|||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.google.common.util.concurrent.MoreExecutors
|
import com.google.common.util.concurrent.MoreExecutors
|
||||||
|
import com.shaarit.domain.audio.StreamResolver
|
||||||
import com.shaarit.domain.model.AudioContentType
|
import com.shaarit.domain.model.AudioContentType
|
||||||
import com.shaarit.domain.model.AudioMedia
|
import com.shaarit.domain.model.AudioMedia
|
||||||
import com.shaarit.domain.model.PlayerState
|
import com.shaarit.domain.model.PlayerState
|
||||||
@ -34,7 +37,8 @@ import javax.inject.Singleton
|
|||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class AudioHandler @Inject constructor(
|
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)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
|
||||||
@ -113,10 +117,18 @@ class AudioHandler @Inject constructor(
|
|||||||
// ========================= Contrôles Publics =========================
|
// ========================= Contrôles Publics =========================
|
||||||
|
|
||||||
fun play(media: AudioMedia) {
|
fun play(media: AudioMedia) {
|
||||||
ensureConnected {
|
// Afficher immédiatement l'état "buffering" dans l'UI
|
||||||
val mediaItem = MediaItem.Builder()
|
_playerState.update { it.copy(currentMedia = media, isBuffering = true) }
|
||||||
|
|
||||||
|
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())
|
.setMediaId(media.id.toString())
|
||||||
.setUri(media.url)
|
.setUri(resolved.streamUrl)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle(media.title)
|
.setTitle(media.title)
|
||||||
@ -124,22 +136,35 @@ class AudioHandler @Inject constructor(
|
|||||||
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
|
||||||
|
|
||||||
_playerState.update { it.copy(currentMedia = media) }
|
// Hint MIME type pour que ExoPlayer choisisse le bon extracteur
|
||||||
it.setMediaItem(mediaItem)
|
if (resolved.mimeType != null) {
|
||||||
it.prepare()
|
mediaItemBuilder.setMimeType(resolved.mimeType)
|
||||||
it.play()
|
}
|
||||||
|
|
||||||
|
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) {
|
fun playAll(mediaList: List<AudioMedia>, startIndex: Int = 0) {
|
||||||
if (mediaList.isEmpty()) return
|
if (mediaList.isEmpty()) return
|
||||||
ensureConnected { mc ->
|
_playerState.update { it.copy(currentMedia = mediaList[startIndex], isBuffering = true) }
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
val items = mediaList.map { media ->
|
val items = mediaList.map { media ->
|
||||||
MediaItem.Builder()
|
val resolved = streamResolver.resolve(media.url)
|
||||||
|
val builder = MediaItem.Builder()
|
||||||
.setMediaId(media.id.toString())
|
.setMediaId(media.id.toString())
|
||||||
.setUri(media.url)
|
.setUri(resolved.streamUrl)
|
||||||
.setMediaMetadata(
|
.setMediaMetadata(
|
||||||
MediaMetadata.Builder()
|
MediaMetadata.Builder()
|
||||||
.setTitle(media.title)
|
.setTitle(media.title)
|
||||||
@ -147,12 +172,20 @@ class AudioHandler @Inject constructor(
|
|||||||
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build()
|
if (resolved.mimeType != null) {
|
||||||
|
builder.setMimeType(resolved.mimeType)
|
||||||
}
|
}
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
ensureConnected { mc ->
|
||||||
mc.setMediaItems(items, startIndex, 0L)
|
mc.setMediaItems(items, startIndex, 0L)
|
||||||
mc.prepare()
|
mc.prepare()
|
||||||
mc.play()
|
mc.play()
|
||||||
_playerState.update { it.copy(currentMedia = mediaList[startIndex]) }
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("AudioHandler", "Failed to resolve/play playlist", e)
|
||||||
|
_playerState.update { it.copy(isBuffering = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -91,6 +91,7 @@ androidx-biometric = { group = "androidx.biometric", name = "biometric", version
|
|||||||
# Media3 (ExoPlayer + MediaSession)
|
# Media3 (ExoPlayer + MediaSession)
|
||||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
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-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-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||||
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user