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:
parent
1deac8850a
commit
2b8134f5b7
77
README.md
77
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.)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,23 +94,76 @@ class MainActivity : FragmentActivity() {
|
||||
onAuthenticated = { isAuthenticated = true }
|
||||
)
|
||||
} else {
|
||||
AppNavGraph(
|
||||
startDestination = startDestination,
|
||||
shareUrl = shareData.url,
|
||||
shareTitle = shareData.title,
|
||||
shareDescription = shareData.description,
|
||||
shareTags = shareData.tags,
|
||||
isFileShare = shareData.isFileShare,
|
||||
initialDeepLink = shareData.deepLink
|
||||
)
|
||||
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,
|
||||
shareTitle = shareData.title,
|
||||
shareDescription = shareData.description,
|
||||
shareTags = shareData.tags,
|
||||
isFileShare = shareData.isFileShare,
|
||||
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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 } ?: ""
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/com/shaarit/domain/model/AudioMedia.kt
Normal file
37
app/src/main/java/com/shaarit/domain/model/AudioMedia.kt
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
241
app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt
Normal file
241
app/src/main/java/com/shaarit/presentation/audio/AudioHandler.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
72
app/src/main/java/com/shaarit/service/AudioPlayerService.kt
Normal file
72
app/src/main/java/com/shaarit/service/AudioPlayerService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user