diff --git a/README.md b/README.md
index 30a9b39..ebc5297 100644
--- a/README.md
+++ b/README.md
@@ -47,13 +47,88 @@
- Mode lecture focus sans distraction
- Barre d'outils de formatage
-### 🤖 Intelligence Artificielle (Google Gemini)
+### Intelligence Artificielle (Google Gemini)
- **Analyse IA d'URL** : Extraction intelligente du titre, description et tags via Gemini
- **Suggestions de tags IA** : Génération automatique de tags pertinents
- **Classification de contenu** : Détection automatique du type (Article, Vidéo, Tutorial, Repository)
- **Fallback multi-modèles** : Essai automatique de plusieurs modèles Gemini (2.5 Flash Lite → 1.5 Flash)
- **Classification en lot** : Scan et classification de tous les bookmarks existants
+### 🏷️ Scanner et Classer (Maintenance)
+La fonction **"Scanner et Classer"** disponible dans les paramètres permet d'analyser automatiquement tous vos bookmarks pour améliorer leur organisation :
+
+#### Fonctionnement automatique
+1. **Analyse des URLs** : Le système examine chaque lien de votre collection
+2. **Détection du type de contenu** : Classification automatique selon 14 catégories :
+ - **Article** : Articles de blog, actualités, publications
+ - **Vidéo** : YouTube, Vimeo, Dailymotion, Twitch, Netflix
+ - **🎵 Musique** : Spotify (tracks/albums/artists/playlists), Deezer, Tidal, Apple Music, YouTube Music, Bandcamp, SoundCloud, Mixcloud, Beatport
+ - **📻 Radio** *(nouveau)* : TuneIn, Radio Garden, myTuner, iHeartRadio, OnlineRadioBox, radio.net, FluxRadios, Radio-Canada OHdio (direct/premiere), flux streaming (.m3u/.m3u8/.pls), serveurs Icecast/Shoutcast
+ - **🎙️ Podcast** : Apple Podcasts, Overcast, Pocket Casts, Castbox, Stitcher, Acast, Anchor, Libsyn, Simplecast, Buzzsprout, Spotify (shows/épisodes), Radio-Canada OHdio (balados), flux RSS/XML audio
+ - **Social** : Facebook, Twitter/X, Instagram, LinkedIn, Reddit, TikTok, Pinterest
+ - **Repository** : GitHub, GitLab, BitBucket, StackOverflow
+ - **Shopping** : Amazon, eBay, Etsy, AliExpress
+ - **Document** : Google Docs, Notion, Trello, Jira, Confluence
+ - **PDF** : Fichiers PDF directs
+ - **Image** : Images directes, Imgur, Flickr
+ - **Newsletter** : Lettres d'information (Substack, Revue, Mailchimp)
+ - **News** : Sites d'actualités (BBC, CNN, Reuters, Le Monde, Le Figaro, The Guardian, New York Times, sites avec "news" dans le domaine)
+ - **Unknown** : Contenu non identifié (sites personnels, blogs non catégorisés, outils spécialisés, contenu ne correspondant à aucune catégorie prédéfinie)
+
+3. **Identification des sites** : Détection automatique du nom du site (YouTube, GitHub, Amazon, etc.)
+4. **Ajout de tags intelligents** : Ajout automatique de tags pertinents selon le type :
+ - `video` pour les vidéos
+ - `music` pour la musique
+ - `radio` pour les radios
+ - `podcast` pour les podcasts
+ - `social` pour les réseaux sociaux
+ - `news` pour les actualités
+ - `repository`, `dev` pour le code
+ - `shopping` pour les achats
+ - `article`, `document`, `pdf`, `image`, `newsletter`
+
+#### Classification audio intelligente (Radio / Podcast / Musique)
+
+L'application utilise un classifieur audio dédié (`AudioClassifier`) qui analyse l'URL avec un **ordre de priorité strict** pour éviter les faux positifs :
+
+1. **RADIO** (priorité la plus haute) — Détecté si :
+ - L'URL pointe vers un flux streaming direct (`.m3u`, `.m3u8`, `.pls`)
+ - L'hôte contient un serveur de streaming (Icecast, Shoutcast, StreamTheWorld)
+ - Le sous-domaine est `stream.*` ou `live.*`
+ - L'URL est sur un agrégateur radio (TuneIn, Radio Garden, iHeartRadio, etc.)
+ - Radio-Canada OHdio : paths `/direct/`, `/premiere/`, `/audio-fil/` (hors balados)
+
+2. **PODCAST** (priorité moyenne) — Détecté si :
+ - L'URL est sur une plateforme dédiée (Apple Podcasts, Overcast, Pocket Casts, Anchor, etc.)
+ - Spotify : paths `/show/` ou `/episode/` uniquement
+ - Radio-Canada OHdio : paths contenant `/balados/`
+ - Flux RSS/XML audio (`.xml`, `.rss`)
+
+3. **MUSIQUE** (priorité standard) — Détecté si :
+ - L'URL est sur un service de streaming musical (Deezer, Tidal, Apple Music, YouTube Music, Bandcamp, SoundCloud, Mixcloud, Beatport)
+ - Spotify : paths `/track/`, `/album/`, `/artist/` ou `/playlist/`
+
+> **Exemple Spotify** : `open.spotify.com/track/...` → Musique, `open.spotify.com/show/...` → Podcast, `open.spotify.com/episode/...` → Podcast
+
+#### Avantages
+- **Organisation automatique** : Vos liens sont catégorisés sans effort manuel
+- **Filtrage amélioré** : Utilisez les tags auto-générés dans le menu latéral pour filtrer
+- **Statistiques précises** : Le tableau de bord affiche la répartition par type de contenu
+- **Recherche optimisée** : Les tags additionnels améliorent la recherche full-text
+
+#### Utilisation
+1. Allez dans **Paramètres** → **Maintenance**
+2. Appuyez sur **"Scanner et Classer"**
+3. Patientez pendant l'analyse (le temps dépend du nombre de liens)
+4. Un message indique le nombre de liens mis à jour
+5. Les nouveaux tags sont immédiatement disponibles pour le filtrage
+
+#### Notes importantes
+- Cette fonction ne modifie que les métadonnées (type, site, tags)
+- Le contenu de vos liens (titre, description, URL) reste inchangé
+- Les tags existants sont conservés, de nouveaux tags sont simplement ajoutés
+- L'opération est sans risque et peut être répétée si nécessaire
+
### 🌐 Extraction de Métadonnées
- Extraction automatique des OpenGraph (titre, description, image) via JSoup
- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index c05ae0e..5c15df7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -153,6 +153,12 @@ dependencies {
// Biometric
implementation(libs.androidx.biometric)
+ // Media3 (ExoPlayer + MediaSession for audio playback)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.exoplayer.hls)
+ implementation(libs.androidx.media3.session)
+ implementation(libs.androidx.media3.ui)
+
// Glance (App Widgets with Compose)
implementation("androidx.glance:glance-appwidget:1.1.0")
implementation("androidx.glance:glance-material3:1.1.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3f8d36d..df6fc24 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
+
+
+
+
+
+
+
+
+ val contentType = when {
+ link.tags.any { it.equals("radio", true) } -> AudioContentType.RADIO
+ link.tags.any { it.equals("podcast", true) } -> AudioContentType.PODCAST
+ else -> AudioContentType.MUSIC
+ }
+ audioHandler.play(
+ AudioMedia(
+ id = link.id,
+ title = link.title,
+ url = link.url,
+ artist = link.siteName,
+ thumbnailUrl = link.thumbnailUrl,
+ contentType = contentType
+ )
+ )
+ }
+ )
+
+ MiniPlayerBar(
+ playerState = playerState,
+ onTogglePlayPause = { audioHandler.togglePlayPause() },
+ onClose = { audioHandler.stop() },
+ onClick = { showFullPlayer = true },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .navigationBarsPadding()
+ )
+ }
+
+ if (showFullPlayer && playerState.isActive) {
+ FullPlayerSheet(
+ playerState = playerState,
+ onDismiss = { showFullPlayer = false },
+ onTogglePlayPause = { audioHandler.togglePlayPause() },
+ onSeek = { fraction -> audioHandler.seekToFraction(fraction) },
+ onNext = { audioHandler.next() },
+ onPrevious = { audioHandler.previous() },
+ onClose = {
+ audioHandler.stop()
+ showFullPlayer = false
+ }
+ )
+ }
}
}
}
}
}
+ override fun onStart() {
+ super.onStart()
+ audioHandler.connect()
+ }
+
override fun onStop() {
super.onStop()
+ audioHandler.disconnect()
lastBackgroundTime = System.currentTimeMillis()
hasBeenBackgrounded = true
}
diff --git a/app/src/main/java/com/shaarit/ShaarItApp.kt b/app/src/main/java/com/shaarit/ShaarItApp.kt
index 091d83f..ee2b319 100644
--- a/app/src/main/java/com/shaarit/ShaarItApp.kt
+++ b/app/src/main/java/com/shaarit/ShaarItApp.kt
@@ -30,6 +30,7 @@ class ShaarItApp : Application(), Configuration.Provider {
setupHealthCheckWorker()
setupWidgetUpdateWorker()
setupReminderNotificationChannel()
+ setupAudioNotificationChannel()
}
private fun setupHealthCheckWorker() {
@@ -70,7 +71,22 @@ class ShaarItApp : Application(), Configuration.Provider {
}
}
+ private fun setupAudioNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_AUDIO,
+ "Lecture audio",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Notifications de lecture audio en arrière-plan"
+ }
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
companion object {
const val CHANNEL_REMINDERS = "reading_reminders"
+ const val CHANNEL_AUDIO = "audio_playback"
}
}
diff --git a/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt
index fae09c1..104b864 100644
--- a/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt
+++ b/app/src/main/java/com/shaarit/data/export/BookmarkImporter.kt
@@ -5,6 +5,7 @@ import android.net.Uri
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.data.local.entity.ContentType
import com.shaarit.data.local.entity.LinkEntity
+import com.shaarit.domain.classifier.AudioClassifier
import com.shaarit.data.local.entity.SyncStatus
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
@@ -198,6 +199,10 @@ class BookmarkImporter @Inject constructor(
}
private fun detectContentType(url: String): ContentType {
+ // Audio classification via AudioClassifier (RADIO > PODCAST > MUSIQUE)
+ val audioType = AudioClassifier.classify(url)
+ if (audioType != null) return audioType
+
return when {
url.contains("youtube.com") || url.contains("youtu.be") ||
url.contains("vimeo.com") || url.contains("dailymotion.com") -> ContentType.VIDEO
@@ -205,9 +210,6 @@ class BookmarkImporter @Inject constructor(
url.contains("github.com") || url.contains("gitlab.com") ||
url.contains("bitbucket.org") -> ContentType.REPOSITORY
- url.contains("spotify.com") || url.contains("deezer.com") ||
- url.contains("soundcloud.com") || url.contains("music.apple.com") -> ContentType.MUSIC
-
url.contains("docs.google.com") || url.contains("notion.so") ||
url.contains("confluence") -> ContentType.DOCUMENT
@@ -222,9 +224,6 @@ class BookmarkImporter @Inject constructor(
url.contains("news") || url.contains("nytimes") || url.contains("lemonde") ||
url.contains("bbc") || url.contains("cnn") -> ContentType.NEWS
- url.matches(Regex(".*\\.(mp3|wav|ogg)$", RegexOption.IGNORE_CASE)) ||
- url.contains("podcast") -> ContentType.PODCAST
-
url.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE
url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF
diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt
index a964ce6..91be284 100644
--- a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt
+++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt
@@ -121,6 +121,7 @@ enum class ContentType {
SHOPPING, // Amazon, etc.
NEWSLETTER,
MUSIC, // Spotify, Deezer, etc.
+ RADIO, // TuneIn, Radio Garden, flux streaming, etc.
NEWS // News sites
}
diff --git a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt
index 22ac45e..bf595e4 100644
--- a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt
+++ b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt
@@ -2,6 +2,7 @@ package com.shaarit.data.metadata
import android.util.Log
import com.shaarit.data.local.entity.ContentType
+import com.shaarit.domain.classifier.AudioClassifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
@@ -28,9 +29,7 @@ class LinkMetadataExtractor @Inject constructor() {
private val SOCIAL_PATTERN = Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")
private val SHOPPING_PATTERN = Regex("amazon|ebay|shopify")
private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp")
- private val MUSIC_PATTERN = Regex("spotify|deezer|soundcloud|bandcamp")
private val NEWS_PATTERN = Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")
- private val PODCAST_PATTERN = Regex("podcast|anchor")
}
/**
@@ -179,6 +178,10 @@ class LinkMetadataExtractor @Inject constructor() {
* Détecte le type de contenu
*/
private fun detectContentType(doc: Document, url: String): ContentType {
+ // Audio classification via AudioClassifier (RADIO > PODCAST > MUSIQUE)
+ val audioType = AudioClassifier.classify(url)
+ if (audioType != null) return audioType
+
val ogType = doc.select("meta[property=og:type]").attr("content")
return when {
@@ -189,9 +192,8 @@ class LinkMetadataExtractor @Inject constructor() {
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
- url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
- doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST
+ doc.select("audio").isNotEmpty() -> ContentType.PODCAST
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
@@ -201,6 +203,10 @@ class LinkMetadataExtractor @Inject constructor() {
* Détecte le type de contenu uniquement depuis l'URL
*/
private fun detectContentTypeFromUrl(url: String): ContentType {
+ // Audio classification via AudioClassifier (RADIO > PODCAST > MUSIQUE)
+ val audioType = AudioClassifier.classify(url)
+ if (audioType != null) return audioType
+
return when {
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
@@ -208,7 +214,6 @@ class LinkMetadataExtractor @Inject constructor() {
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
- url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
diff --git a/app/src/main/java/com/shaarit/domain/classifier/AudioClassifier.kt b/app/src/main/java/com/shaarit/domain/classifier/AudioClassifier.kt
new file mode 100644
index 0000000..757e4a9
--- /dev/null
+++ b/app/src/main/java/com/shaarit/domain/classifier/AudioClassifier.kt
@@ -0,0 +1,255 @@
+package com.shaarit.domain.classifier
+
+import com.shaarit.data.local.entity.ContentType
+import java.net.URI
+
+/**
+ * Classifieur audio dédié pour distinguer RADIO, PODCAST et MUSIQUE.
+ * Utilise des tableaux de configuration (Regex/domaines) pour faciliter
+ * l'ajout de nouvelles sources sans modifier la logique.
+ *
+ * Ordre de priorité : RADIO > PODCAST > MUSIQUE
+ */
+object AudioClassifier {
+
+ // =========================================================================
+ // RADIO — Patterns de détection
+ // =========================================================================
+
+ /** Extensions de flux streaming (toujours radio) */
+ private val RADIO_STREAM_EXTENSIONS = Regex("\\.(m3u|m3u8|pls)$", RegexOption.IGNORE_CASE)
+
+ /** Extensions audio ambiguës — radio seulement si combinées avec un indicateur de stream */
+ private val AUDIO_STREAM_EXTENSIONS = Regex("\\.(mp3|aac)$", RegexOption.IGNORE_CASE)
+
+ /** Patterns d'hôte indiquant un flux de streaming direct */
+ private val RADIO_STREAM_HOST_PATTERNS = listOf(
+ "playerservices.streamtheworld.com",
+ "icecast",
+ "shoutcast"
+ )
+
+ /** Sous-domaines typiques de flux live (stream.*, live.*) */
+ private fun hasStreamSubdomain(host: String): Boolean {
+ return host.startsWith("stream.") || host.startsWith("live.")
+ }
+
+ /** Plateformes et agrégateurs radio */
+ private val RADIO_PLATFORMS = listOf(
+ "fluxradios.com",
+ "tunein.com",
+ "radio.garden",
+ "mytuner-radio.com",
+ "iheart.com",
+ "onlineradiobox.com",
+ "radio.net"
+ )
+
+ /** Radio-Canada OHdio — patterns de radio en direct */
+ private val RADIO_CANADA_LIVE_PATTERN = Regex("/(direct|premiere|audio-fil)/", RegexOption.IGNORE_CASE)
+
+ /** Radio-Canada OHdio — pattern balado (à exclure du live) */
+ private val RADIO_CANADA_BALADO_PATTERN = Regex("/balados/|/ohdio/balados/", RegexOption.IGNORE_CASE)
+
+ // =========================================================================
+ // PODCAST — Patterns de détection
+ // =========================================================================
+
+ /** Plateformes dédiées aux podcasts */
+ private val PODCAST_PLATFORMS = listOf(
+ "podcasts.apple.com",
+ "overcast.fm",
+ "pocketcasts.com",
+ "castbox.fm",
+ "stitcher.com",
+ "acast.com",
+ "anchor.fm",
+ "libsyn.com",
+ "simplecast.com",
+ "buzzsprout.com"
+ )
+
+ /** Spotify — shows et épisodes = podcast */
+ private val SPOTIFY_PODCAST_PATTERN = Regex("open\\.spotify\\.com/(show|episode)/")
+
+ /** Flux RSS/XML audio (potentiellement podcast) */
+ private val PODCAST_FEED_EXTENSIONS = Regex("\\.(xml|rss)$", RegexOption.IGNORE_CASE)
+
+ // =========================================================================
+ // MUSIQUE — Patterns de détection
+ // =========================================================================
+
+ /** Services de streaming musical */
+ private val MUSIC_PLATFORMS = listOf(
+ "music.apple.com",
+ "deezer.com",
+ "tidal.com",
+ "music.youtube.com",
+ "bandcamp.com",
+ "soundcloud.com",
+ "mixcloud.com",
+ "beatport.com"
+ )
+
+ /** Spotify — tracks, albums, artistes, playlists = musique */
+ private val SPOTIFY_MUSIC_PATTERN = Regex("open\\.spotify\\.com/(track|album|artist|playlist)/")
+
+ // =========================================================================
+ // SITE NAME — Détection des noms de plateformes audio
+ // =========================================================================
+
+ /** Map domaine → nom de site pour les plateformes audio connues */
+ private val AUDIO_SITE_NAMES = listOf(
+ // Musique
+ "spotify.com" to "Spotify",
+ "deezer.com" to "Deezer",
+ "soundcloud.com" to "SoundCloud",
+ "music.apple.com" to "Apple Music",
+ "music.youtube.com" to "YouTube Music",
+ "bandcamp.com" to "Bandcamp",
+ "mixcloud.com" to "Mixcloud",
+ "tidal.com" to "Tidal",
+ "beatport.com" to "Beatport",
+ // Radio
+ "tunein.com" to "TuneIn",
+ "radio.garden" to "Radio Garden",
+ "mytuner-radio.com" to "myTuner",
+ "iheart.com" to "iHeartRadio",
+ "onlineradiobox.com" to "OnlineRadioBox",
+ "radio.net" to "radio.net",
+ "fluxradios.com" to "FluxRadios",
+ "ici.radio-canada.ca" to "Radio-Canada",
+ // Podcast
+ "podcasts.apple.com" to "Apple Podcasts",
+ "overcast.fm" to "Overcast",
+ "pocketcasts.com" to "Pocket Casts",
+ "castbox.fm" to "Castbox",
+ "stitcher.com" to "Stitcher",
+ "acast.com" to "Acast",
+ "anchor.fm" to "Anchor",
+ "libsyn.com" to "Libsyn",
+ "simplecast.com" to "Simplecast",
+ "buzzsprout.com" to "Buzzsprout"
+ )
+
+ // =========================================================================
+ // API publique
+ // =========================================================================
+
+ /**
+ * Classifie un lien audio. Retourne null si le lien n'est pas audio.
+ * Ordre de priorité : RADIO > PODCAST > MUSIQUE
+ */
+ fun classify(url: String): ContentType? {
+ val lowerUrl = url.lowercase()
+ val host = extractHost(url)
+ val path = extractPath(url)
+
+ // A. RADIO (priorité la plus haute)
+ if (isRadio(lowerUrl, host, path)) return ContentType.RADIO
+
+ // B. PODCAST
+ if (isPodcast(lowerUrl, host, path)) return ContentType.PODCAST
+
+ // C. MUSIQUE
+ if (isMusic(lowerUrl, host)) return ContentType.MUSIC
+
+ return null
+ }
+
+ /**
+ * Détecte le nom du site pour les plateformes audio connues.
+ * Retourne null si le domaine n'est pas une plateforme audio reconnue.
+ */
+ fun detectSiteName(url: String): String? {
+ val host = extractHost(url)
+ return AUDIO_SITE_NAMES.firstOrNull { (domain, _) -> host.contains(domain) }?.second
+ }
+
+ /**
+ * Vérifie si l'URL correspond à un domaine audio connu (radio, podcast ou musique).
+ */
+ fun isAudioUrl(url: String): Boolean = classify(url) != null
+
+ // =========================================================================
+ // Détection interne
+ // =========================================================================
+
+ private fun isRadio(url: String, host: String, path: String): Boolean {
+ // 1. Extensions de flux streaming (.m3u, .m3u8, .pls)
+ if (RADIO_STREAM_EXTENSIONS.containsMatchIn(url)) return true
+
+ // 2. Hôte contient un pattern de streaming (icecast, shoutcast, streamtheworld)
+ if (RADIO_STREAM_HOST_PATTERNS.any { host.contains(it) }) return true
+
+ // 3. Sous-domaine stream.*/live.* (indicateur de flux live)
+ if (hasStreamSubdomain(host)) return true
+
+ // 4. Extensions audio (.mp3, .aac) + indicateur de streaming dans l'URL
+ if (AUDIO_STREAM_EXTENSIONS.containsMatchIn(url) && looksLikeStream(url, host)) return true
+
+ // 5. Plateformes et agrégateurs radio
+ if (RADIO_PLATFORMS.any { host.contains(it) }) return true
+
+ // 6. Radio-Canada OHdio — direct/premiere/audio-fil (exclure balados)
+ if (host.contains("ici.radio-canada.ca")) {
+ if (RADIO_CANADA_LIVE_PATTERN.containsMatchIn(path) &&
+ !RADIO_CANADA_BALADO_PATTERN.containsMatchIn(path)) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ private fun isPodcast(url: String, host: String, path: String): Boolean {
+ // 1. Plateformes dédiées aux podcasts
+ if (PODCAST_PLATFORMS.any { host.contains(it) }) return true
+
+ // 2. Spotify — shows et épisodes
+ if (SPOTIFY_PODCAST_PATTERN.containsMatchIn(url)) return true
+
+ // 3. Radio-Canada OHdio — balados
+ if (host.contains("ici.radio-canada.ca") &&
+ RADIO_CANADA_BALADO_PATTERN.containsMatchIn(path)) {
+ return true
+ }
+
+ // 4. Flux RSS/XML (potentiellement podcast)
+ if (PODCAST_FEED_EXTENSIONS.containsMatchIn(url)) return true
+
+ return false
+ }
+
+ private fun isMusic(url: String, host: String): Boolean {
+ // 1. Plateformes de streaming musical
+ if (MUSIC_PLATFORMS.any { host.contains(it) }) return true
+
+ // 2. Spotify — tracks, albums, artistes, playlists
+ if (SPOTIFY_MUSIC_PATTERN.containsMatchIn(url)) return true
+
+ return false
+ }
+
+ // =========================================================================
+ // Utilitaires
+ // =========================================================================
+
+ /**
+ * Vérifie si l'URL ressemble à un flux de streaming audio
+ * (pour les extensions .mp3/.aac ambiguës)
+ */
+ private fun looksLikeStream(url: String, host: String): Boolean {
+ return RADIO_STREAM_HOST_PATTERNS.any { url.contains(it) } ||
+ hasStreamSubdomain(host) ||
+ RADIO_PLATFORMS.any { url.contains(it) }
+ }
+
+ private fun extractHost(url: String): String {
+ return try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""
+ }
+
+ private fun extractPath(url: String): String {
+ return try { URI(url).path?.lowercase() } catch (e: Exception) { null } ?: ""
+ }
+}
diff --git a/app/src/main/java/com/shaarit/domain/model/AudioMedia.kt b/app/src/main/java/com/shaarit/domain/model/AudioMedia.kt
new file mode 100644
index 0000000..f44c22f
--- /dev/null
+++ b/app/src/main/java/com/shaarit/domain/model/AudioMedia.kt
@@ -0,0 +1,37 @@
+package com.shaarit.domain.model
+
+/**
+ * Représente un média audio à lire dans le lecteur global.
+ */
+data class AudioMedia(
+ val id: Int,
+ val title: String,
+ val url: String,
+ val artist: String? = null,
+ val thumbnailUrl: String? = null,
+ val contentType: AudioContentType = AudioContentType.MUSIC
+)
+
+enum class AudioContentType {
+ PODCAST,
+ RADIO,
+ MUSIC
+}
+
+/**
+ * État observable du lecteur audio global.
+ */
+data class PlayerState(
+ val currentMedia: AudioMedia? = null,
+ val isPlaying: Boolean = false,
+ val currentPosition: Long = 0L,
+ val duration: Long = 0L,
+ val isBuffering: Boolean = false,
+ val hasNext: Boolean = false,
+ val hasPrevious: Boolean = false
+) {
+ val isActive: Boolean get() = currentMedia != null
+
+ val progressFraction: Float
+ get() = if (duration > 0L) (currentPosition.toFloat() / duration).coerceIn(0f, 1f) else 0f
+}
diff --git a/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt
index 912309c..aa5fb12 100644
--- a/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt
+++ b/app/src/main/java/com/shaarit/domain/usecase/ClassifyBookmarksUseCase.kt
@@ -1,6 +1,7 @@
package com.shaarit.domain.usecase
import com.shaarit.data.local.entity.ContentType
+import com.shaarit.domain.classifier.AudioClassifier
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import kotlinx.coroutines.flow.firstOrNull
@@ -63,6 +64,7 @@ class ClassifyBookmarksUseCase @Inject constructor(
ContentType.REPOSITORY -> listOf("repository", "dev")
ContentType.SHOPPING -> listOf("shopping")
ContentType.PODCAST -> listOf("podcast")
+ ContentType.RADIO -> listOf("radio")
ContentType.ARTICLE -> listOf("article")
ContentType.DOCUMENT -> listOf("document")
ContentType.PDF -> listOf("pdf")
@@ -77,41 +79,18 @@ class ClassifyBookmarksUseCase @Inject constructor(
val lowerUrl = url.lowercase()
val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""
- // Site Name detection
- val siteName = when {
- host.contains("youtube.com") || host.contains("youtu.be") -> "YouTube"
- host.contains("facebook.com") -> "Facebook"
- host.contains("twitter.com") || host.contains("x.com") -> "Twitter"
- host.contains("instagram.com") -> "Instagram"
- host.contains("tiktok.com") -> "TikTok"
- host.contains("linkedin.com") -> "LinkedIn"
- host.contains("github.com") -> "GitHub"
- host.contains("gitlab.com") -> "GitLab"
- host.contains("medium.com") -> "Medium"
- host.contains("reddit.com") -> "Reddit"
- host.contains("spotify.com") -> "Spotify"
- host.contains("deezer.com") -> "Deezer"
- host.contains("soundcloud.com") -> "SoundCloud"
- host.contains("amazon") -> "Amazon"
- host.contains("netflix.com") -> "Netflix"
- host.contains("stackoverflow.com") -> "StackOverflow"
- host.contains("pinterest.com") -> "Pinterest"
- host.contains("twitch.tv") -> "Twitch"
- host.contains("wikipedia.org") -> "Wikipedia"
- host.contains("nytimes.com") -> "NY Times"
- host.contains("lemonde.fr") -> "Le Monde"
- host.contains("bbc.com") || host.contains("bbc.co.uk") -> "BBC"
- host.contains("cnn.com") -> "CNN"
- else -> null
+ // Audio classification via AudioClassifier (RADIO > PODCAST > MUSIQUE)
+ val audioType = AudioClassifier.classify(url)
+ if (audioType != null) {
+ val siteName = AudioClassifier.detectSiteName(url) ?: detectSiteName(host)
+ return Pair(audioType, siteName)
}
- // Content Type detection
- val type = when {
- // Music
- host.contains("spotify.com") || host.contains("deezer.com") ||
- host.contains("soundcloud.com") || host.contains("music.apple.com") ||
- host.contains("bandcamp.com") -> ContentType.MUSIC
+ // Site Name detection (non-audio)
+ val siteName = detectSiteName(host)
+ // Content Type detection (non-audio)
+ val type = when {
// Video
host.contains("youtube.com") || host.contains("youtu.be") ||
host.contains("vimeo.com") || host.contains("dailymotion.com") ||
@@ -155,4 +134,33 @@ class ClassifyBookmarksUseCase @Inject constructor(
return Pair(type, siteName)
}
+
+ /**
+ * Détecte le nom du site pour les plateformes non-audio
+ */
+ private fun detectSiteName(host: String): String? {
+ return when {
+ host.contains("youtube.com") || host.contains("youtu.be") -> "YouTube"
+ host.contains("facebook.com") -> "Facebook"
+ host.contains("twitter.com") || host.contains("x.com") -> "Twitter"
+ host.contains("instagram.com") -> "Instagram"
+ host.contains("tiktok.com") -> "TikTok"
+ host.contains("linkedin.com") -> "LinkedIn"
+ host.contains("github.com") -> "GitHub"
+ host.contains("gitlab.com") -> "GitLab"
+ host.contains("medium.com") -> "Medium"
+ host.contains("reddit.com") -> "Reddit"
+ host.contains("amazon") -> "Amazon"
+ host.contains("netflix.com") -> "Netflix"
+ host.contains("stackoverflow.com") -> "StackOverflow"
+ host.contains("pinterest.com") -> "Pinterest"
+ host.contains("twitch.tv") -> "Twitch"
+ host.contains("wikipedia.org") -> "Wikipedia"
+ host.contains("nytimes.com") -> "NY Times"
+ host.contains("lemonde.fr") -> "Le Monde"
+ host.contains("bbc.com") || host.contains("bbc.co.uk") -> "BBC"
+ host.contains("cnn.com") -> "CNN"
+ else -> null
+ }
+ }
}
diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
index cca40f4..8cfee86 100644
--- a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt
@@ -281,6 +281,7 @@ fun AddLinkScreen(
"PODCAST" -> Icons.Default.Headphones
"REPOSITORY" -> Icons.Default.Code
"MUSIC" -> Icons.Default.MusicNote
+ "RADIO" -> Icons.Default.Radio
"NEWS" -> Icons.Default.Newspaper
"SHOPPING" -> Icons.Default.ShoppingCart
"SOCIAL" -> Icons.Default.Share
diff --git a/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt b/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt
new file mode 100644
index 0000000..c50e471
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt
@@ -0,0 +1,241 @@
+package com.shaarit.presentation.audio
+
+import android.content.ComponentName
+import android.content.Context
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
+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.model.AudioContentType
+import com.shaarit.domain.model.AudioMedia
+import com.shaarit.domain.model.PlayerState
+import com.shaarit.service.AudioPlayerService
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Singleton pont entre l'UI Compose et le AudioPlayerService.
+ * Expose un StateFlow observable par tous les composables.
+ */
+@Singleton
+class AudioHandler @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+
+ private val _playerState = MutableStateFlow(PlayerState())
+ val playerState: StateFlow = _playerState.asStateFlow()
+
+ private var controllerFuture: ListenableFuture? = null
+ private var controller: MediaController? = null
+ private var progressJob: Job? = null
+
+ private val playerListener = object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _playerState.update { it.copy(isPlaying = isPlaying) }
+ if (isPlaying) startProgressUpdates() else stopProgressUpdates()
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ _playerState.update {
+ it.copy(
+ isBuffering = playbackState == Player.STATE_BUFFERING,
+ duration = controller?.duration?.coerceAtLeast(0L) ?: 0L
+ )
+ }
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ updateCurrentMedia(mediaItem)
+ _playerState.update {
+ it.copy(
+ hasNext = controller?.hasNextMediaItem() == true,
+ hasPrevious = controller?.hasPreviousMediaItem() == true
+ )
+ }
+ }
+ }
+
+ /**
+ * Connecte le handler au MediaSessionService.
+ * Doit être appelé quand l'activité est visible.
+ */
+ fun connect() {
+ if (controller != null) return
+ val sessionToken = SessionToken(context, ComponentName(context, AudioPlayerService::class.java))
+ controllerFuture = MediaController.Builder(context, sessionToken).buildAsync().also { future ->
+ future.addListener({
+ controller = future.get().also { mc ->
+ mc.addListener(playerListener)
+ // Sync initial state
+ _playerState.update {
+ it.copy(
+ isPlaying = mc.isPlaying,
+ duration = mc.duration.coerceAtLeast(0L),
+ isBuffering = mc.playbackState == Player.STATE_BUFFERING,
+ hasNext = mc.hasNextMediaItem(),
+ hasPrevious = mc.hasPreviousMediaItem()
+ )
+ }
+ updateCurrentMedia(mc.currentMediaItem)
+ if (mc.isPlaying) startProgressUpdates()
+ }
+ }, MoreExecutors.directExecutor())
+ }
+ }
+
+ /**
+ * Déconnecte le handler (quand l'activité n'est plus visible).
+ */
+ fun disconnect() {
+ stopProgressUpdates()
+ controller?.removeListener(playerListener)
+ controllerFuture?.let { MediaController.releaseFuture(it) }
+ controller = null
+ controllerFuture = null
+ }
+
+ // ========================= 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()
+
+ _playerState.update { it.copy(currentMedia = media) }
+ it.setMediaItem(mediaItem)
+ it.prepare()
+ it.play()
+ }
+ }
+
+ 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()
+ }
+ mc.setMediaItems(items, startIndex, 0L)
+ mc.prepare()
+ mc.play()
+ _playerState.update { it.copy(currentMedia = mediaList[startIndex]) }
+ }
+ }
+
+ fun togglePlayPause() {
+ controller?.let {
+ if (it.isPlaying) it.pause() else it.play()
+ }
+ }
+
+ fun seekTo(positionMs: Long) {
+ controller?.seekTo(positionMs)
+ _playerState.update { it.copy(currentPosition = positionMs) }
+ }
+
+ fun seekToFraction(fraction: Float) {
+ val duration = _playerState.value.duration
+ if (duration > 0L) seekTo((fraction * duration).toLong())
+ }
+
+ fun next() { controller?.seekToNextMediaItem() }
+ fun previous() { controller?.seekToPreviousMediaItem() }
+
+ fun stop() {
+ controller?.stop()
+ controller?.clearMediaItems()
+ stopProgressUpdates()
+ _playerState.value = PlayerState()
+ }
+
+ // ========================= Helpers Internes =========================
+
+ private fun ensureConnected(action: (MediaController) -> Unit) {
+ val mc = controller
+ if (mc != null) {
+ action(mc)
+ } else {
+ connect()
+ controllerFuture?.addListener({
+ controller?.let(action)
+ }, MoreExecutors.directExecutor())
+ }
+ }
+
+ private fun updateCurrentMedia(mediaItem: MediaItem?) {
+ if (mediaItem == null) {
+ _playerState.update { it.copy(currentMedia = null) }
+ return
+ }
+ val meta = mediaItem.mediaMetadata
+ _playerState.update {
+ it.copy(
+ currentMedia = AudioMedia(
+ id = mediaItem.mediaId.toIntOrNull() ?: 0,
+ title = meta.title?.toString() ?: "Sans titre",
+ url = mediaItem.localConfiguration?.uri?.toString() ?: "",
+ artist = meta.artist?.toString(),
+ thumbnailUrl = meta.artworkUri?.toString(),
+ contentType = AudioContentType.MUSIC
+ ),
+ duration = controller?.duration?.coerceAtLeast(0L) ?: 0L
+ )
+ }
+ }
+
+ private fun startProgressUpdates() {
+ stopProgressUpdates()
+ progressJob = scope.launch {
+ while (isActive) {
+ controller?.let { mc ->
+ _playerState.update {
+ it.copy(
+ currentPosition = mc.currentPosition.coerceAtLeast(0L),
+ duration = mc.duration.coerceAtLeast(0L)
+ )
+ }
+ }
+ delay(500L)
+ }
+ }
+ }
+
+ private fun stopProgressUpdates() {
+ progressJob?.cancel()
+ progressJob = null
+ }
+}
diff --git a/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt b/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt
new file mode 100644
index 0000000..221eb25
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/audio/FullPlayerSheet.kt
@@ -0,0 +1,294 @@
+package com.shaarit.presentation.audio
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Radio
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.shaarit.domain.model.AudioContentType
+import com.shaarit.domain.model.PlayerState
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+
+/**
+ * Lecteur audio complet affiché dans une ModalBottomSheet.
+ * Style glassmorphism cohérent avec le reste de l'app.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FullPlayerSheet(
+ playerState: PlayerState,
+ onDismiss: () -> Unit,
+ onTogglePlayPause: () -> Unit,
+ onSeek: (Float) -> Unit,
+ onNext: () -> Unit,
+ onPrevious: () -> Unit,
+ onClose: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.background,
+ dragHandle = {
+ // Drag handle personnalisé
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .width(40.dp)
+ .height(4.dp)
+ .clip(RoundedCornerShape(2.dp))
+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
+ )
+ }
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 48.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Bouton chevron pour fermer
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.align(Alignment.Start)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = "Réduire",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Grande icône / jaquette
+ Box(
+ modifier = Modifier
+ .size(200.dp)
+ .clip(RoundedCornerShape(24.dp))
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f),
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)
+ )
+ )
+ )
+ .border(
+ width = 1.dp,
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ )
+ ),
+ shape = RoundedCornerShape(24.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ val icon = when (playerState.currentMedia?.contentType) {
+ AudioContentType.PODCAST -> Icons.Default.Headphones
+ AudioContentType.RADIO -> Icons.Default.Radio
+ else -> Icons.Default.MusicNote
+ }
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(80.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Titre
+ Text(
+ text = playerState.currentMedia?.title ?: "Sans titre",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Artiste / Type
+ Text(
+ text = playerState.currentMedia?.artist
+ ?: playerState.currentMedia?.contentType?.name ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Seekbar
+ var sliderPosition by remember { mutableFloatStateOf(playerState.progressFraction) }
+ var isDragging by remember { mutableStateOf(false) }
+
+ val displayPosition = if (isDragging) sliderPosition else playerState.progressFraction
+
+ Slider(
+ value = displayPosition,
+ onValueChange = { value ->
+ isDragging = true
+ sliderPosition = value
+ },
+ onValueChangeFinished = {
+ onSeek(sliderPosition)
+ isDragging = false
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = SliderDefaults.colors(
+ thumbColor = MaterialTheme.colorScheme.primary,
+ activeTrackColor = MaterialTheme.colorScheme.primary,
+ inactiveTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
+ )
+ )
+
+ // Temps
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = formatDuration(playerState.currentPosition),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = formatDuration(playerState.duration),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Contrôles de lecture
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Previous
+ IconButton(
+ onClick = onPrevious,
+ enabled = playerState.hasPrevious,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipPrevious,
+ contentDescription = "Précédent",
+ tint = if (playerState.hasPrevious)
+ MaterialTheme.colorScheme.onBackground
+ else
+ MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ // Play/Pause (grand bouton)
+ IconButton(
+ onClick = onTogglePlayPause,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ ) {
+ if (playerState.isBuffering) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp),
+ strokeWidth = 3.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Icon(
+ imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause" else "Lecture",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(36.dp)
+ )
+ }
+ }
+
+ // Next
+ IconButton(
+ onClick = onNext,
+ enabled = playerState.hasNext,
+ modifier = Modifier.size(48.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipNext,
+ contentDescription = "Suivant",
+ tint = if (playerState.hasNext)
+ MaterialTheme.colorScheme.onBackground
+ else
+ MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // URL source
+ playerState.currentMedia?.url?.let { url ->
+ Text(
+ text = url,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+}
+
+private fun formatDuration(ms: Long): String {
+ if (ms <= 0L) return "0:00"
+ val totalSeconds = TimeUnit.MILLISECONDS.toSeconds(ms)
+ val minutes = totalSeconds / 60
+ val seconds = totalSeconds % 60
+ return String.format(Locale.US, "%d:%02d", minutes, seconds)
+}
diff --git a/app/src/main/java/com/shaarit/presentation/audio/MiniPlayerBar.kt b/app/src/main/java/com/shaarit/presentation/audio/MiniPlayerBar.kt
new file mode 100644
index 0000000..c2e69bf
--- /dev/null
+++ b/app/src/main/java/com/shaarit/presentation/audio/MiniPlayerBar.kt
@@ -0,0 +1,177 @@
+package com.shaarit.presentation.audio
+
+import androidx.compose.animation.*
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Radio
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.shaarit.domain.model.AudioContentType
+import com.shaarit.domain.model.PlayerState
+
+/**
+ * Mini player persistent en bas de l'écran, style glassmorphism.
+ * Visible dès qu'un média est chargé, indépendamment de la navigation.
+ */
+@Composable
+fun MiniPlayerBar(
+ playerState: PlayerState,
+ onTogglePlayPause: () -> Unit,
+ onClose: () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ AnimatedVisibility(
+ visible = playerState.isActive,
+ enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(300)),
+ exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(200)),
+ modifier = modifier
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .shadow(
+ elevation = 12.dp,
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
+ ambientColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
+ spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.25f)
+ )
+ .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
+ .background(
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.97f),
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.92f)
+ )
+ )
+ )
+ .border(
+ width = 1.dp,
+ brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.05f)
+ )
+ ),
+ shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
+ )
+ .clickable(onClick = onClick)
+ ) {
+ // Progress bar subtile en haut
+ LinearProgressIndicator(
+ progress = playerState.progressFraction,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(2.dp),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Icône du type de contenu
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center
+ ) {
+ val icon = when (playerState.currentMedia?.contentType) {
+ AudioContentType.PODCAST -> Icons.Default.Headphones
+ AudioContentType.RADIO -> Icons.Default.Radio
+ else -> Icons.Default.MusicNote
+ }
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ // Titre + artiste
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = playerState.currentMedia?.title ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ playerState.currentMedia?.artist?.let { artist ->
+ Text(
+ text = artist,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ // Bouton Play/Pause
+ IconButton(
+ onClick = onTogglePlayPause,
+ modifier = Modifier
+ .size(38.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ ) {
+ if (playerState.isBuffering) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ } else {
+ Icon(
+ imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (playerState.isPlaying) "Pause" else "Lecture",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ }
+
+ // Bouton Fermer
+ IconButton(
+ onClick = onClose,
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Fermer le lecteur",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt
index 009251e..50dee2f 100644
--- a/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/dashboard/DashboardScreen.kt
@@ -267,6 +267,7 @@ private fun ContentTypeBar(
ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
ContentType.MUSIC -> Triple(Icons.Default.MusicNote, "Musique", Color(0xFFE91E63))
+ ContentType.RADIO -> Triple(Icons.Default.Radio, "Radio", Color(0xFF00BCD4))
ContentType.NEWS -> Triple(Icons.Default.Newspaper, "Actualités", Color(0xFFF44336))
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
}
diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
index 335d05d..37e631e 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt
@@ -281,6 +281,7 @@ fun FeedScreen(
onNavigateToPinned: () -> Unit = {},
onNavigateToReader: (Int) -> Unit = {},
onNavigateToReminders: () -> Unit = {},
+ onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
initialTagFilter: String? = null,
initialCollectionId: Long? = null,
viewModel: FeedViewModel = hiltViewModel()
@@ -574,6 +575,15 @@ fun FeedScreen(
}
)
+ DrawerNavigationItem(
+ icon = Icons.Default.Radio,
+ label = "Radio",
+ onClick = {
+ scope.launch { drawerState.close() }
+ viewModel.setTagFilter("radio")
+ }
+ )
+
DrawerNavigationItem(
icon = Icons.Default.Newspaper,
label = "Actualités",
@@ -1470,7 +1480,8 @@ fun FeedScreen(
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) },
- hasReminder = linkIdsWithReminders.contains(link.id)
+ hasReminder = linkIdsWithReminders.contains(link.id),
+ onPlayClick = onPlayAudio
)
}
}
@@ -1532,7 +1543,8 @@ fun FeedScreen(
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) },
- hasReminder = linkIdsWithReminders.contains(link.id)
+ hasReminder = linkIdsWithReminders.contains(link.id),
+ onPlayClick = onPlayAudio
)
}
}
@@ -1595,7 +1607,8 @@ fun FeedScreen(
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) },
- hasReminder = linkIdsWithReminders.contains(link.id)
+ hasReminder = linkIdsWithReminders.contains(link.id),
+ onPlayClick = onPlayAudio
)
}
}
diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
index d4f74d3..51e7c8f 100644
--- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
+++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Alarm
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.window.DialogProperties
@@ -48,6 +49,13 @@ import coil.request.ImageRequest
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
+private val AUDIO_TAGS = setOf("podcast", "radio", "music", "audio")
+
+fun ShaarliLink.isAudioPlayable(): Boolean {
+ return tags.any { it.lowercase() in AUDIO_TAGS } ||
+ contentType?.lowercase() in AUDIO_TAGS
+}
+
/**
* Full list view item - shows all details including markdown description
*/
@@ -65,7 +73,8 @@ fun ListViewItem(
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {},
- hasReminder: Boolean = false
+ hasReminder: Boolean = false,
+ onPlayClick: ((ShaarliLink) -> Unit)? = null
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -148,6 +157,19 @@ fun ListViewItem(
onCheckedChange = { onItemClick?.invoke() }
)
}
+ if (link.isAudioPlayable() && onPlayClick != null) {
+ IconButton(
+ onClick = { onPlayClick(link) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = "Lire l'audio",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
// Pin button
IconButton(
onClick = {
@@ -275,7 +297,8 @@ fun GridViewItem(
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {},
- hasReminder: Boolean = false
+ hasReminder: Boolean = false,
+ onPlayClick: ((ShaarliLink) -> Unit)? = null
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -440,6 +463,19 @@ fun GridViewItem(
}
Row {
+ if (link.isAudioPlayable() && onPlayClick != null) {
+ IconButton(
+ onClick = { onPlayClick(link) },
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = "Lire l'audio",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
// Pin button
IconButton(
onClick = {
@@ -511,7 +547,8 @@ fun CompactViewItem(
onEditClick: (Int) -> Unit,
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {},
- hasReminder: Boolean = false
+ hasReminder: Boolean = false,
+ onPlayClick: ((ShaarliLink) -> Unit)? = null
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -627,6 +664,19 @@ fun CompactViewItem(
}
Row {
+ if (link.isAudioPlayable() && onPlayClick != null) {
+ IconButton(
+ onClick = { onPlayClick(link) },
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = "Lire l'audio",
+ tint = MaterialTheme.colorScheme.tertiary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
IconButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
index 7bd5917..58d0772 100644
--- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
+++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt
@@ -58,7 +58,8 @@ fun AppNavGraph(
shareDescription: String? = null,
shareTags: List? = null,
isFileShare: Boolean = false,
- initialDeepLink: String? = null
+ initialDeepLink: String? = null,
+ onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null
) {
val navController = rememberNavController()
val context = LocalContext.current
@@ -161,6 +162,7 @@ fun AppNavGraph(
navController.navigate(Screen.Reader.createRoute(linkId))
},
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
+ onPlayAudio = onPlayAudio,
initialTagFilter = tag,
initialCollectionId = collectionId
)
diff --git a/app/src/main/java/com/shaarit/service/AudioPlayerService.kt b/app/src/main/java/com/shaarit/service/AudioPlayerService.kt
new file mode 100644
index 0000000..9a3401b
--- /dev/null
+++ b/app/src/main/java/com/shaarit/service/AudioPlayerService.kt
@@ -0,0 +1,72 @@
+package com.shaarit.service
+
+import android.app.PendingIntent
+import android.content.Intent
+import androidx.annotation.OptIn
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+import com.shaarit.MainActivity
+
+/**
+ * Service Foreground de lecture audio utilisant Media3.
+ * Gère la MediaSession, la notification système (play/pause/next/prev),
+ * le focus audio et la lecture en arrière-plan.
+ */
+class AudioPlayerService : MediaSessionService() {
+
+ private var mediaSession: MediaSession? = null
+
+ @OptIn(UnstableApi::class)
+ override fun onCreate() {
+ super.onCreate()
+
+ val player = ExoPlayer.Builder(this)
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .setUsage(C.USAGE_MEDIA)
+ .build(),
+ /* handleAudioFocus = */ true
+ )
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+
+ // PendingIntent qui ramène vers l'activité au clic sur la notification
+ val sessionActivityIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ },
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ mediaSession = MediaSession.Builder(this, player)
+ .setSessionActivity(sessionActivityIntent)
+ .build()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
+ return mediaSession
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ val player = mediaSession?.player
+ if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
+ stopSelf()
+ }
+ }
+
+ override fun onDestroy() {
+ mediaSession?.run {
+ player.release()
+ release()
+ }
+ mediaSession = null
+ super.onDestroy()
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0be0fbd..23a4c85 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -25,6 +25,7 @@ dataStore = "1.0.0"
kotlinxSerialization = "1.6.2"
coil = "2.6.0"
biometric = "1.1.0"
+media3 = "1.2.1"
[libraries]
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@@ -87,6 +88,12 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Biometric
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
+# 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-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
+androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }