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" }