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)
|
||||
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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
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.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,10 +117,18 @@ class AudioHandler @Inject constructor(
|
||||
// ========================= Contrôles Publics =========================
|
||||
|
||||
fun play(media: AudioMedia) {
|
||||
ensureConnected {
|
||||
val mediaItem = MediaItem.Builder()
|
||||
// Afficher immédiatement l'état "buffering" dans l'UI
|
||||
_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())
|
||||
.setUri(media.url)
|
||||
.setUri(resolved.streamUrl)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(media.title)
|
||||
@ -124,22 +136,35 @@ class AudioHandler @Inject constructor(
|
||||
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
_playerState.update { it.copy(currentMedia = media) }
|
||||
it.setMediaItem(mediaItem)
|
||||
it.prepare()
|
||||
it.play()
|
||||
// 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 ->
|
||||
_playerState.update { it.copy(currentMedia = mediaList[startIndex], isBuffering = true) }
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
val items = mediaList.map { media ->
|
||||
MediaItem.Builder()
|
||||
val resolved = streamResolver.resolve(media.url)
|
||||
val builder = MediaItem.Builder()
|
||||
.setMediaId(media.id.toString())
|
||||
.setUri(media.url)
|
||||
.setUri(resolved.streamUrl)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(media.title)
|
||||
@ -147,12 +172,20 @@ class AudioHandler @Inject constructor(
|
||||
.setArtworkUri(media.thumbnailUrl?.let { android.net.Uri.parse(it) })
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
if (resolved.mimeType != null) {
|
||||
builder.setMimeType(resolved.mimeType)
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
ensureConnected { mc ->
|
||||
mc.setMediaItems(items, startIndex, 0L)
|
||||
mc.prepare()
|
||||
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)
|
||||
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" }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user