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
|
- Mode lecture focus sans distraction
|
||||||
- Barre d'outils de formatage
|
- 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
|
- **Analyse IA d'URL** : Extraction intelligente du titre, description et tags via Gemini
|
||||||
- **Suggestions de tags IA** : Génération automatique de tags pertinents
|
- **Suggestions de tags IA** : Génération automatique de tags pertinents
|
||||||
- **Classification de contenu** : Détection automatique du type (Article, Vidéo, Tutorial, Repository)
|
- **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)
|
- **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
|
- **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 de Métadonnées
|
||||||
- Extraction automatique des OpenGraph (titre, description, image) via JSoup
|
- Extraction automatique des OpenGraph (titre, description, image) via JSoup
|
||||||
- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
|
- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
|
||||||
|
|||||||
@ -153,6 +153,12 @@ dependencies {
|
|||||||
// Biometric
|
// Biometric
|
||||||
implementation(libs.androidx.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)
|
// Glance (App Widgets with Compose)
|
||||||
implementation("androidx.glance:glance-appwidget:1.1.0")
|
implementation("androidx.glance:glance-appwidget:1.1.0")
|
||||||
implementation("androidx.glance:glance-material3: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.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<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
|
<application
|
||||||
android:name=".ShaarItApp"
|
android:name=".ShaarItApp"
|
||||||
@ -68,6 +70,16 @@
|
|||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</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 -->
|
<!-- Quick Settings Tile -->
|
||||||
<service
|
<service
|
||||||
android:name=".service.AddLinkTileService"
|
android:name=".service.AddLinkTileService"
|
||||||
|
|||||||
@ -5,13 +5,16 @@ import android.os.Bundle
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
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.BiometricAuthManager
|
||||||
import com.shaarit.core.storage.SecurityPreferences
|
import com.shaarit.core.storage.SecurityPreferences
|
||||||
import com.shaarit.presentation.auth.LockScreen
|
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.presentation.nav.AppNavGraph
|
||||||
import com.shaarit.ui.theme.ShaarItTheme
|
import com.shaarit.ui.theme.ShaarItTheme
|
||||||
import com.shaarit.ui.theme.ThemePreferences
|
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 tokenManager: com.shaarit.core.storage.TokenManager
|
||||||
@Inject lateinit var securityPreferences: SecurityPreferences
|
@Inject lateinit var securityPreferences: SecurityPreferences
|
||||||
@Inject lateinit var biometricAuthManager: BiometricAuthManager
|
@Inject lateinit var biometricAuthManager: BiometricAuthManager
|
||||||
|
@Inject lateinit var audioHandler: AudioHandler
|
||||||
|
|
||||||
// Start as authenticated — lock only triggers after app goes to background
|
// Start as authenticated — lock only triggers after app goes to background
|
||||||
private var isAuthenticated by mutableStateOf(true)
|
private var isAuthenticated by mutableStateOf(true)
|
||||||
@ -85,23 +94,76 @@ class MainActivity : FragmentActivity() {
|
|||||||
onAuthenticated = { isAuthenticated = true }
|
onAuthenticated = { isAuthenticated = true }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AppNavGraph(
|
val playerState by audioHandler.playerState.collectAsState()
|
||||||
startDestination = startDestination,
|
var showFullPlayer by androidx.compose.runtime.remember { mutableStateOf(false) }
|
||||||
shareUrl = shareData.url,
|
|
||||||
shareTitle = shareData.title,
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
shareDescription = shareData.description,
|
AppNavGraph(
|
||||||
shareTags = shareData.tags,
|
startDestination = startDestination,
|
||||||
isFileShare = shareData.isFileShare,
|
shareUrl = shareData.url,
|
||||||
initialDeepLink = shareData.deepLink
|
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() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
audioHandler.disconnect()
|
||||||
lastBackgroundTime = System.currentTimeMillis()
|
lastBackgroundTime = System.currentTimeMillis()
|
||||||
hasBeenBackgrounded = true
|
hasBeenBackgrounded = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class ShaarItApp : Application(), Configuration.Provider {
|
|||||||
setupHealthCheckWorker()
|
setupHealthCheckWorker()
|
||||||
setupWidgetUpdateWorker()
|
setupWidgetUpdateWorker()
|
||||||
setupReminderNotificationChannel()
|
setupReminderNotificationChannel()
|
||||||
|
setupAudioNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupHealthCheckWorker() {
|
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 {
|
companion object {
|
||||||
const val CHANNEL_REMINDERS = "reading_reminders"
|
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.dao.LinkDao
|
||||||
import com.shaarit.data.local.entity.ContentType
|
import com.shaarit.data.local.entity.ContentType
|
||||||
import com.shaarit.data.local.entity.LinkEntity
|
import com.shaarit.data.local.entity.LinkEntity
|
||||||
|
import com.shaarit.domain.classifier.AudioClassifier
|
||||||
import com.shaarit.data.local.entity.SyncStatus
|
import com.shaarit.data.local.entity.SyncStatus
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -198,6 +199,10 @@ class BookmarkImporter @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun detectContentType(url: String): ContentType {
|
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 {
|
return when {
|
||||||
url.contains("youtube.com") || url.contains("youtu.be") ||
|
url.contains("youtube.com") || url.contains("youtu.be") ||
|
||||||
url.contains("vimeo.com") || url.contains("dailymotion.com") -> ContentType.VIDEO
|
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("github.com") || url.contains("gitlab.com") ||
|
||||||
url.contains("bitbucket.org") -> ContentType.REPOSITORY
|
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("docs.google.com") || url.contains("notion.so") ||
|
||||||
url.contains("confluence") -> ContentType.DOCUMENT
|
url.contains("confluence") -> ContentType.DOCUMENT
|
||||||
|
|
||||||
@ -222,9 +224,6 @@ class BookmarkImporter @Inject constructor(
|
|||||||
url.contains("news") || url.contains("nytimes") || url.contains("lemonde") ||
|
url.contains("news") || url.contains("nytimes") || url.contains("lemonde") ||
|
||||||
url.contains("bbc") || url.contains("cnn") -> ContentType.NEWS
|
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.matches(Regex(".*\\.(jpg|jpeg|png|gif|webp)$", RegexOption.IGNORE_CASE)) -> ContentType.IMAGE
|
||||||
|
|
||||||
url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF
|
url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF
|
||||||
|
|||||||
@ -121,6 +121,7 @@ enum class ContentType {
|
|||||||
SHOPPING, // Amazon, etc.
|
SHOPPING, // Amazon, etc.
|
||||||
NEWSLETTER,
|
NEWSLETTER,
|
||||||
MUSIC, // Spotify, Deezer, etc.
|
MUSIC, // Spotify, Deezer, etc.
|
||||||
|
RADIO, // TuneIn, Radio Garden, flux streaming, etc.
|
||||||
NEWS // News sites
|
NEWS // News sites
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.shaarit.data.metadata
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.shaarit.data.local.entity.ContentType
|
import com.shaarit.data.local.entity.ContentType
|
||||||
|
import com.shaarit.domain.classifier.AudioClassifier
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jsoup.Jsoup
|
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 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 SHOPPING_PATTERN = Regex("amazon|ebay|shopify")
|
||||||
private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp")
|
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 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
|
* Détecte le type de contenu
|
||||||
*/
|
*/
|
||||||
private fun detectContentType(doc: Document, url: String): ContentType {
|
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")
|
val ogType = doc.select("meta[property=og:type]").attr("content")
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
@ -189,9 +192,8 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
||||||
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
||||||
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
||||||
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
|
|
||||||
url.contains(NEWS_PATTERN) -> ContentType.NEWS
|
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
|
url.endsWith(".pdf") -> ContentType.PDF
|
||||||
else -> ContentType.UNKNOWN
|
else -> ContentType.UNKNOWN
|
||||||
}
|
}
|
||||||
@ -201,6 +203,10 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
* Détecte le type de contenu uniquement depuis l'URL
|
* Détecte le type de contenu uniquement depuis l'URL
|
||||||
*/
|
*/
|
||||||
private fun detectContentTypeFromUrl(url: String): ContentType {
|
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 {
|
return when {
|
||||||
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
|
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
|
||||||
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
|
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
|
||||||
@ -208,7 +214,6 @@ class LinkMetadataExtractor @Inject constructor() {
|
|||||||
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
|
||||||
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
|
||||||
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
|
||||||
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
|
|
||||||
url.contains(NEWS_PATTERN) -> ContentType.NEWS
|
url.contains(NEWS_PATTERN) -> ContentType.NEWS
|
||||||
url.endsWith(".pdf") -> ContentType.PDF
|
url.endsWith(".pdf") -> ContentType.PDF
|
||||||
else -> ContentType.UNKNOWN
|
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
|
package com.shaarit.domain.usecase
|
||||||
|
|
||||||
import com.shaarit.data.local.entity.ContentType
|
import com.shaarit.data.local.entity.ContentType
|
||||||
|
import com.shaarit.domain.classifier.AudioClassifier
|
||||||
import com.shaarit.domain.model.ShaarliLink
|
import com.shaarit.domain.model.ShaarliLink
|
||||||
import com.shaarit.domain.repository.LinkRepository
|
import com.shaarit.domain.repository.LinkRepository
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
@ -63,6 +64,7 @@ class ClassifyBookmarksUseCase @Inject constructor(
|
|||||||
ContentType.REPOSITORY -> listOf("repository", "dev")
|
ContentType.REPOSITORY -> listOf("repository", "dev")
|
||||||
ContentType.SHOPPING -> listOf("shopping")
|
ContentType.SHOPPING -> listOf("shopping")
|
||||||
ContentType.PODCAST -> listOf("podcast")
|
ContentType.PODCAST -> listOf("podcast")
|
||||||
|
ContentType.RADIO -> listOf("radio")
|
||||||
ContentType.ARTICLE -> listOf("article")
|
ContentType.ARTICLE -> listOf("article")
|
||||||
ContentType.DOCUMENT -> listOf("document")
|
ContentType.DOCUMENT -> listOf("document")
|
||||||
ContentType.PDF -> listOf("pdf")
|
ContentType.PDF -> listOf("pdf")
|
||||||
@ -77,41 +79,18 @@ class ClassifyBookmarksUseCase @Inject constructor(
|
|||||||
val lowerUrl = url.lowercase()
|
val lowerUrl = url.lowercase()
|
||||||
val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""
|
val host = try { URI(url).host?.lowercase() } catch (e: Exception) { null } ?: ""
|
||||||
|
|
||||||
// Site Name detection
|
// Audio classification via AudioClassifier (RADIO > PODCAST > MUSIQUE)
|
||||||
val siteName = when {
|
val audioType = AudioClassifier.classify(url)
|
||||||
host.contains("youtube.com") || host.contains("youtu.be") -> "YouTube"
|
if (audioType != null) {
|
||||||
host.contains("facebook.com") -> "Facebook"
|
val siteName = AudioClassifier.detectSiteName(url) ?: detectSiteName(host)
|
||||||
host.contains("twitter.com") || host.contains("x.com") -> "Twitter"
|
return Pair(audioType, siteName)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content Type detection
|
// Site Name detection (non-audio)
|
||||||
val type = when {
|
val siteName = detectSiteName(host)
|
||||||
// Music
|
|
||||||
host.contains("spotify.com") || host.contains("deezer.com") ||
|
|
||||||
host.contains("soundcloud.com") || host.contains("music.apple.com") ||
|
|
||||||
host.contains("bandcamp.com") -> ContentType.MUSIC
|
|
||||||
|
|
||||||
|
// Content Type detection (non-audio)
|
||||||
|
val type = when {
|
||||||
// Video
|
// Video
|
||||||
host.contains("youtube.com") || host.contains("youtu.be") ||
|
host.contains("youtube.com") || host.contains("youtu.be") ||
|
||||||
host.contains("vimeo.com") || host.contains("dailymotion.com") ||
|
host.contains("vimeo.com") || host.contains("dailymotion.com") ||
|
||||||
@ -155,4 +134,33 @@ class ClassifyBookmarksUseCase @Inject constructor(
|
|||||||
|
|
||||||
return Pair(type, siteName)
|
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
|
"PODCAST" -> Icons.Default.Headphones
|
||||||
"REPOSITORY" -> Icons.Default.Code
|
"REPOSITORY" -> Icons.Default.Code
|
||||||
"MUSIC" -> Icons.Default.MusicNote
|
"MUSIC" -> Icons.Default.MusicNote
|
||||||
|
"RADIO" -> Icons.Default.Radio
|
||||||
"NEWS" -> Icons.Default.Newspaper
|
"NEWS" -> Icons.Default.Newspaper
|
||||||
"SHOPPING" -> Icons.Default.ShoppingCart
|
"SHOPPING" -> Icons.Default.ShoppingCart
|
||||||
"SOCIAL" -> Icons.Default.Share
|
"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.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
|
||||||
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
|
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
|
||||||
ContentType.MUSIC -> Triple(Icons.Default.MusicNote, "Musique", Color(0xFFE91E63))
|
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.NEWS -> Triple(Icons.Default.Newspaper, "Actualités", Color(0xFFF44336))
|
||||||
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
|
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -281,6 +281,7 @@ fun FeedScreen(
|
|||||||
onNavigateToPinned: () -> Unit = {},
|
onNavigateToPinned: () -> Unit = {},
|
||||||
onNavigateToReader: (Int) -> Unit = {},
|
onNavigateToReader: (Int) -> Unit = {},
|
||||||
onNavigateToReminders: () -> Unit = {},
|
onNavigateToReminders: () -> Unit = {},
|
||||||
|
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null,
|
||||||
initialTagFilter: String? = null,
|
initialTagFilter: String? = null,
|
||||||
initialCollectionId: Long? = null,
|
initialCollectionId: Long? = null,
|
||||||
viewModel: FeedViewModel = hiltViewModel()
|
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(
|
DrawerNavigationItem(
|
||||||
icon = Icons.Default.Newspaper,
|
icon = Icons.Default.Newspaper,
|
||||||
label = "Actualités",
|
label = "Actualités",
|
||||||
@ -1470,7 +1480,8 @@ fun FeedScreen(
|
|||||||
onEditClick = onNavigateToEdit,
|
onEditClick = onNavigateToEdit,
|
||||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||||
onTogglePin = { id -> viewModel.togglePin(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,
|
onEditClick = onNavigateToEdit,
|
||||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||||
onTogglePin = { id -> viewModel.togglePin(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,
|
onEditClick = onNavigateToEdit,
|
||||||
onDeleteClick = { viewModel.deleteLink(link.id) },
|
onDeleteClick = { viewModel.deleteLink(link.id) },
|
||||||
onTogglePin = { id -> viewModel.togglePin(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.Warning
|
||||||
import androidx.compose.material.icons.filled.MenuBook
|
import androidx.compose.material.icons.filled.MenuBook
|
||||||
import androidx.compose.material.icons.filled.Alarm
|
import androidx.compose.material.icons.filled.Alarm
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@ -48,6 +49,13 @@ import coil.request.ImageRequest
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.graphics.Color
|
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
|
* Full list view item - shows all details including markdown description
|
||||||
*/
|
*/
|
||||||
@ -65,7 +73,8 @@ fun ListViewItem(
|
|||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onTogglePin: (Int) -> Unit = {},
|
onTogglePin: (Int) -> Unit = {},
|
||||||
hasReminder: Boolean = false
|
hasReminder: Boolean = false,
|
||||||
|
onPlayClick: ((ShaarliLink) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
@ -148,6 +157,19 @@ fun ListViewItem(
|
|||||||
onCheckedChange = { onItemClick?.invoke() }
|
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
|
// Pin button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@ -275,7 +297,8 @@ fun GridViewItem(
|
|||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onTogglePin: (Int) -> Unit = {},
|
onTogglePin: (Int) -> Unit = {},
|
||||||
hasReminder: Boolean = false
|
hasReminder: Boolean = false,
|
||||||
|
onPlayClick: ((ShaarliLink) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
@ -440,6 +463,19 @@ fun GridViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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
|
// Pin button
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@ -511,7 +547,8 @@ fun CompactViewItem(
|
|||||||
onEditClick: (Int) -> Unit,
|
onEditClick: (Int) -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onTogglePin: (Int) -> Unit = {},
|
onTogglePin: (Int) -> Unit = {},
|
||||||
hasReminder: Boolean = false
|
hasReminder: Boolean = false,
|
||||||
|
onPlayClick: ((ShaarliLink) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
@ -627,6 +664,19 @@ fun CompactViewItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
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(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
|||||||
@ -58,7 +58,8 @@ fun AppNavGraph(
|
|||||||
shareDescription: String? = null,
|
shareDescription: String? = null,
|
||||||
shareTags: List<String>? = null,
|
shareTags: List<String>? = null,
|
||||||
isFileShare: Boolean = false,
|
isFileShare: Boolean = false,
|
||||||
initialDeepLink: String? = null
|
initialDeepLink: String? = null,
|
||||||
|
onPlayAudio: ((com.shaarit.domain.model.ShaarliLink) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -161,6 +162,7 @@ fun AppNavGraph(
|
|||||||
navController.navigate(Screen.Reader.createRoute(linkId))
|
navController.navigate(Screen.Reader.createRoute(linkId))
|
||||||
},
|
},
|
||||||
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
onNavigateToReminders = { navController.navigate(Screen.Reminders.route) },
|
||||||
|
onPlayAudio = onPlayAudio,
|
||||||
initialTagFilter = tag,
|
initialTagFilter = tag,
|
||||||
initialCollectionId = collectionId
|
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"
|
kotlinxSerialization = "1.6.2"
|
||||||
coil = "2.6.0"
|
coil = "2.6.0"
|
||||||
biometric = "1.1.0"
|
biometric = "1.1.0"
|
||||||
|
media3 = "1.2.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
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
|
# Biometric
|
||||||
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user