diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c15df7..7390017 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt index 736c99e..f8109e4 100644 --- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -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 diff --git a/app/src/main/java/com/shaarit/domain/audio/StreamResolver.kt b/app/src/main/java/com/shaarit/domain/audio/StreamResolver.kt new file mode 100644 index 0000000..b53e18f --- /dev/null +++ b/app/src/main/java/com/shaarit/domain/audio/StreamResolver.kt @@ -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 ) + * - 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 + */ + private fun resolveAsx(url: String): ResolvedStream { + val body = fetchBody(url) + // ASX est du XML simple. On cherche + val refRegex = Regex(""" found in ASX: $url") + Log.d(TAG, "ASX resolved: $streamUrl") + return resolveIfNeeded(streamUrl) + } + + /** + * Parse un flux RSS/XML (podcast) et extrait l'URL du premier épisode. + */ + private fun resolveRssFeed(url: String): ResolvedStream { + val body = fetchBody(url) + + // Cherche dans le premier + val enclosureRegex = Regex( + """]+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 dans un + val mediaRegex = Regex( + """]+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) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt b/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt index c50e471..cb568e0 100644 --- a/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt +++ b/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt @@ -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, 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]) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 23a4c85..6b2c14f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }