feat: Add audio playback system with Media3 and comprehensive audio content classification

- Add Media3 dependencies (ExoPlayer, HLS, MediaSession, UI) for audio streaming
- Implement AudioPlayerService as MediaSessionService with foreground playback support
- Create AudioHandler for playback control and AudioMedia domain model with AudioContentType enum
- Add MiniPlayerBar and FullPlayerSheet UI components with play/pause, seek, and navigation controls
- Implement AudioClassifier with strict priority
This commit is contained in:
Bruno Charest 2026-02-11 11:23:22 -05:00
parent 1deac8850a
commit 2b8134f5b7
21 changed files with 1394 additions and 60 deletions

View File

@ -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.)

View File

@ -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")

View File

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application
android:name=".ShaarItApp"
@ -68,6 +70,16 @@
android:resource="@xml/shortcuts" />
</activity>
<!-- Audio Player Service (Media3 MediaSessionService) -->
<service
android:name=".service.AudioPlayerService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<!-- Quick Settings Tile -->
<service
android:name=".service.AddLinkTileService"

View File

@ -5,13 +5,16 @@ import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -19,6 +22,11 @@ import androidx.core.view.WindowCompat
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.presentation.auth.LockScreen
import com.shaarit.domain.model.AudioContentType
import com.shaarit.domain.model.AudioMedia
import com.shaarit.presentation.audio.AudioHandler
import com.shaarit.presentation.audio.FullPlayerSheet
import com.shaarit.presentation.audio.MiniPlayerBar
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
import com.shaarit.ui.theme.ThemePreferences
@ -34,6 +42,7 @@ class MainActivity : FragmentActivity() {
@Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager
@Inject lateinit var securityPreferences: SecurityPreferences
@Inject lateinit var biometricAuthManager: BiometricAuthManager
@Inject lateinit var audioHandler: AudioHandler
// Start as authenticated — lock only triggers after app goes to background
private var isAuthenticated by mutableStateOf(true)
@ -85,6 +94,10 @@ class MainActivity : FragmentActivity() {
onAuthenticated = { isAuthenticated = true }
)
} else {
val playerState by audioHandler.playerState.collectAsState()
var showFullPlayer by androidx.compose.runtime.remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
AppNavGraph(
startDestination = startDestination,
shareUrl = shareData.url,
@ -92,16 +105,65 @@ class MainActivity : FragmentActivity() {
shareDescription = shareData.description,
shareTags = shareData.tags,
isFileShare = shareData.isFileShare,
initialDeepLink = shareData.deepLink
initialDeepLink = shareData.deepLink,
onPlayAudio = { link ->
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
}

View File

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

View File

@ -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

View File

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

View File

@ -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

View File

@ -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 } ?: ""
}
}

View File

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

View File

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

View File

@ -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

View File

@ -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<PlayerState> 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> = _playerState.asStateFlow()
private var controllerFuture: ListenableFuture<MediaController>? = 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<AudioMedia>, 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
}
}

View File

@ -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)
}

View File

@ -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)
)
}
}
}
}
}

View File

@ -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))
}

View File

@ -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
)
}
}

View File

@ -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)

View File

@ -58,7 +58,8 @@ fun AppNavGraph(
shareDescription: String? = null,
shareTags: List<String>? = 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
)

View File

@ -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()
}
}

View File

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