Refactor music repository

This commit is contained in:
MM20 2022-05-26 14:04:25 +02:00
parent 0165ac5dc8
commit c3257846c1
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
5 changed files with 346 additions and 244 deletions

View File

@ -89,7 +89,6 @@ dependencies {
implementation(libs.androidx.fragment)
implementation(libs.androidx.core)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media2)
implementation(libs.materialcomponents.core)
implementation(libs.androidx.constraintlayout)

View File

@ -37,10 +37,9 @@ android {
dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.media2)
implementation(libs.koin.android)
implementation(libs.coil.core)
implementation(project(":ktx"))
implementation(project(":preferences"))

View File

@ -1,32 +1,34 @@
package de.mm20.launcher2.music
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.support.v4.media.session.MediaSessionCompat
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.KeyEvent
import androidx.core.app.NotificationCompat
import androidx.core.content.edit
import androidx.core.graphics.scale
import androidx.media2.common.MediaItem
import androidx.media2.common.MediaMetadata
import androidx.media2.common.SessionPlayer
import androidx.media2.session.MediaController
import androidx.media2.session.SessionCommandGroup
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.size.Scale
import de.mm20.launcher2.notifications.NotificationRepository
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Semaphore
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
import java.util.concurrent.Executors
import java.io.IOException
interface MusicRepository {
val playbackState: Flow<PlaybackState>
@ -55,218 +57,361 @@ internal class MusicRepositoryImpl(
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val dataStore: LauncherDataStore by inject()
override val playbackState = MutableStateFlow(PlaybackState.Stopped)
override val title = MutableStateFlow<String?>(null)
override val artist = MutableStateFlow<String?>(null)
override val album = MutableStateFlow<String?>(null)
override val albumArt = MutableStateFlow<Bitmap?>(null)
private var lastPlayer: String? = null
private var lastToken: String? = null
private val semaphore = Semaphore(permits = 1)
init {
scope.launch {
notificationRepository.notifications
.mapNotNull {
it
.sortedByDescending { it.postTime }
.find {
it.notification.category == Notification.CATEGORY_TRANSPORT || it.notification.category == Notification.CATEGORY_SERVICE
}
}
.collectLatest {
val token =
it.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token
?: return@collectLatest
setMediaSession(
MediaSessionCompat.Token.fromToken(token),
it.packageName
)
}
}
private val preferences: SharedPreferences by lazy {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
private fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) {
if (token.toString() == lastToken.toString()) return
scope.launch {
val filterMusicApps = dataStore.data.map { it.musicWidget.filterSources }.first()
if (filterMusicApps && !isMusicApp(packageName)) {
return@launch
}
try {
semaphore.acquire()
mediaController?.close()
val appName = context.packageManager.getPackageInfo(
packageName,
0
).applicationInfo.loadLabel(context.packageManager)
mediaController = MediaController.Builder(context)
.setSessionCompatToken(token)
.setControllerCallback(
Executors.newSingleThreadExecutor(),
mediaSessionCallback
)
.build()
setMetadata(
title = context.getString(R.string.music_widget_default_title, appName),
artist = null,
album = null,
albumArt = null,
packageName
)
lastToken = token.toString()
} finally {
semaphore.release()
private var lastPlayerPackage: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_LAST_PLAYER, null)
}
return field
}
}
private var mediaController: MediaController? = null
set(value) {
if (value == null) {
playbackState.value = PlaybackState.Stopped
preferences.edit {
putString(PREFS_KEY_LAST_PLAYER, value)
}
field = value
}
private val mediaSessionCallback = object : MediaController.ControllerCallback() {
override fun onConnected(
controller: MediaController,
allowedCommands: SessionCommandGroup
) {
super.onConnected(controller, allowedCommands)
if (controller != mediaController) return
updateMetadata(controller.currentMediaItem, controller.connectedToken?.packageName)
updateState(controller.playerState)
}
private val currentMediaController: SharedFlow<MediaController?> =
combine(
notificationRepository.notifications,
dataStore.data.map { it.musicWidget.filterSources }
) { notifications, filter ->
withContext(Dispatchers.Default) {
val musicApps = if (filter) getMusicApps() else null
val sbn: StatusBarNotification? = notifications.filter {
it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null &&
(musicApps?.contains(it.packageName) != false)
}.maxByOrNull { it.postTime }
override fun onCurrentMediaItemChanged(controller: MediaController, item: MediaItem?) {
super.onCurrentMediaItemChanged(controller, item)
if (controller != mediaController) return
updateMetadata(item, controller.connectedToken?.packageName)
}
override fun onPlayerStateChanged(controller: MediaController, state: Int) {
super.onPlayerStateChanged(controller, state)
if (controller != mediaController) return
updateState(state)
}
override fun onDisconnected(controller: MediaController) {
super.onDisconnected(controller)
if (controller != mediaController) return
mediaController = null
}
}
private fun updateMetadata(mediaItem: MediaItem?, playerPackage: String?) {
val metadata = mediaItem?.metadata ?: return
val title = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
?: metadata.getString(MediaMetadata.METADATA_KEY_TITLE) ?: return
val artist = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
?: metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
?: metadata.getString(MediaMetadata.METADATA_KEY_COMPOSER)
?: metadata.getString(MediaMetadata.METADATA_KEY_AUTHOR)
?: metadata.getString(MediaMetadata.METADATA_KEY_WRITER)
?: return
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM)
val albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
// Hack for Spotify sending inconsistent metadata updates
if (playerPackage == "com.spotify.music" && album == null) return
scope.launch {
setMetadata(title, artist, album, albumArt, playerPackage)
}
}
private fun updateState(playerState: Int) {
val playbackState = when (playerState) {
SessionPlayer.PLAYER_STATE_PLAYING -> PlaybackState.Playing
SessionPlayer.PLAYER_STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped
}
this.playbackState.value = playbackState
}
init {
loadLastPlaybackMetadata()
}
private fun loadLastPlaybackMetadata() {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
lastPlayer = prefs.getString(PREFS_KEY_LAST_PLAYER, null)
title.value = prefs.getString(PREFS_KEY_TITLE, null)
artist.value = prefs.getString(PREFS_KEY_ARTIST, null)
album.value = prefs.getString(PREFS_KEY_ALBUM, null)
if (prefs.getString(PREFS_KEY_ALBUM_ART, "null") == "null") {
albumArt.value = null
} else scope.launch {
val albumArt = withContext(Dispatchers.IO) {
BitmapFactory.decodeFile(File(context.cacheDir, "album_art").absolutePath)
return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token)
}
this@MusicRepositoryImpl.albumArt.value = albumArt
}
playbackState.value = PlaybackState.Stopped
.distinctUntilChanged()
.map { token ->
if (token == null) return@map null
else {
return@map MediaController(context, token).also {
lastPlayerPackage = it.packageName
}
}
}
.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private val currentMetadata: SharedFlow<MediaMetadata?> = channelFlow {
currentMediaController.collectLatest { controller ->
if (controller == null) {
send(null)
return@collectLatest
}
send(controller.metadata)
val callback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) {
super.onMetadataChanged(metadata)
trySend(metadata)
}
}
try {
controller.registerCallback(callback, Handler(Looper.getMainLooper()))
awaitCancellation()
} finally {
controller.unregisterCallback(callback)
}
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
override val playbackState: SharedFlow<PlaybackState> = channelFlow {
currentMediaController.collectLatest { controller ->
if (controller == null) return@collectLatest send(PlaybackState.Stopped)
send(
when (controller.playbackState?.state) {
android.media.session.PlaybackState.STATE_PLAYING -> PlaybackState.Playing
android.media.session.PlaybackState.STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped
}
)
val callback = object : MediaController.Callback() {
override fun onPlaybackStateChanged(state: android.media.session.PlaybackState?) {
super.onPlaybackStateChanged(state)
trySend(
when (state?.state) {
android.media.session.PlaybackState.STATE_PLAYING -> PlaybackState.Playing
android.media.session.PlaybackState.STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped
}
)
}
}
try {
controller.registerCallback(callback, Handler(Looper.getMainLooper()))
awaitCancellation()
} finally {
controller.unregisterCallback(callback)
}
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastTitle: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_TITLE, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_TITLE, value)
}
field = value
}
override val title: Flow<String?> = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastTitle)
return@collectLatest
}
val title = metadata.getString(MediaMetadata.METADATA_KEY_TITLE)
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
?: currentMediaController.firstOrNull()?.packageName?.let { pkg ->
getAppLabel(pkg)?.let {
context.getString(
R.string.music_widget_default_title,
it
)
}
}
lastTitle = title
send(title)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastArtist: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_ARTIST, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_ARTIST, value)
}
field = value
}
override val artist: Flow<String?> = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastArtist)
return@collectLatest
}
val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST)
?: metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE)
?: currentMediaController.firstOrNull()?.packageName?.let { pkg ->
getAppLabel(pkg)
}
lastArtist = artist
send(artist)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private var lastAlbum: String? = null
get() {
if (field == null) {
field = preferences.getString(PREFS_KEY_ALBUM, null)
}
return field
}
set(value) {
preferences.edit {
putString(PREFS_KEY_ALBUM, value)
}
field = value
}
override val album = channelFlow {
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
send(lastAlbum)
return@collectLatest
}
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM)
lastAlbum = album
send(album)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
override val albumArt: Flow<Bitmap?> = channelFlow {
val size = context.resources.getDimensionPixelSize(R.dimen.album_art_size)
currentMetadata.collectLatest { metadata ->
if (metadata == null) {
val isNull = preferences.getString(PREFS_KEY_ALBUM_ART, "null") == "null"
if (isNull) {
send(null)
} else {
val bmp: Bitmap? = withContext(Dispatchers.IO) {
val file = java.io.File(context.filesDir, "album_art")
val request = ImageRequest.Builder(context)
.data(file)
.size(size)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
}
send(bmp)
}
return@collectLatest
}
val bitmap =
metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)?.let { resize(it, size) }
?: metadata.getBitmap(MediaMetadata.METADATA_KEY_ART)?.let { resize(it, size) }
?: metadata.getString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI)
?.let { loadBitmapFromUri(Uri.parse(it), size) }
?: metadata.getString(MediaMetadata.METADATA_KEY_ART_URI)
?.let { loadBitmapFromUri(Uri.parse(it), size) }
withContext(Dispatchers.IO) {
if (bitmap == null) {
preferences.edit {
putString(PREFS_KEY_ALBUM_ART, "null")
}
} else {
val file = java.io.File(context.filesDir, "album_art")
bitmap.compress(Bitmap.CompressFormat.PNG, 100, file.outputStream())
preferences.edit {
putString(PREFS_KEY_ALBUM_ART, "notnull")
}
}
}
send(bitmap)
}
}.shareIn(scope, SharingStarted.WhileSubscribed(), 1)
private suspend fun loadBitmapFromUri(uri: Uri, size: Int): Bitmap? {
try {
val request = ImageRequest.Builder(context)
.data(uri)
.size(size)
.scale(Scale.FILL)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
} catch (e: IOException) {
} catch (e: SecurityException) {
}
return null
}
private suspend fun resize(bitmap: Bitmap, size: Int): Bitmap? {
return withContext(Dispatchers.IO) {
val request = ImageRequest.Builder(context).data(bitmap)
.size(size)
.scale(Scale.FILL)
.build()
context.imageLoader.execute(request).drawable?.toBitmap()
}
}
private fun getAppLabel(packageName: String): String? {
return try {
context
.packageManager
.getPackageInfo(packageName, 0).applicationInfo
.loadLabel(context.packageManager).toString()
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
override fun previous() {
if (mediaController?.skipToPreviousPlaylistItem()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(upEvent)
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.skipToPrevious()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun next() {
if (mediaController?.skipToNextPlaylistItem()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(upEvent)
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.skipToNext()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun play() {
if (mediaController?.play()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(upEvent)
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.play()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun pause() {
if (mediaController?.pause()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(upEvent)
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null) {
controller.transportControls.pause()
} else {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(downEvent)
val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE)
audioManager.dispatchMediaKeyEvent(upEvent)
}
}
}
override fun togglePause() {
if (playbackState.value != PlaybackState.Playing) play() else pause()
scope.launch {
val controller = currentMediaController.firstOrNull()
if (controller != null && controller.playbackState?.state == android.media.session.PlaybackState.STATE_PLAYING) {
pause()
} else {
play()
}
}
}
override fun openPlayer(): PendingIntent? {
mediaController?.sessionActivity?.let {
val controller = currentMediaController.replayCache.firstOrNull()
controller?.sessionActivity?.let {
return it
}
val intent = lastPlayer?.let {
val packageName = controller?.packageName ?: lastPlayerPackage
val intent = packageName?.let {
context.packageManager.getLaunchIntentForPackage(it)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@ -296,61 +441,23 @@ internal class MusicRepositoryImpl(
)
}
private suspend fun isMusicApp(packageName: String): Boolean {
val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
return !withContext(Dispatchers.IO) {
context.packageManager.queryIntentActivities(intent, 0)
.none { it.activityInfo.packageName == packageName }
}
}
private suspend fun setMetadata(
title: String?,
artist: String?,
album: String?,
albumArt: Bitmap?,
playerPackage: String?
) {
withContext(Dispatchers.IO) {
if (albumArt == null) {
this@MusicRepositoryImpl.albumArt.value = null
} else {
val size = context.resources.getDimension(R.dimen.album_art_size)
val (scaledW, scaledH) = if (albumArt.width > albumArt.height) {
size * albumArt.width / albumArt.height to size
} else {
size to size * albumArt.height / albumArt.width
}
val scaledBitmap = albumArt.scale(scaledW.toInt(), scaledH.toInt())
val file = File(context.cacheDir, "album_art")
val outStream = file.outputStream()
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, outStream)
outStream.close()
this@MusicRepositoryImpl.albumArt.value = scaledBitmap
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit {
putString(PREFS_KEY_TITLE, title)
putString(PREFS_KEY_ARTIST, artist)
putString(PREFS_KEY_ALBUM, album)
putString(PREFS_KEY_LAST_PLAYER, playerPackage)
putString(PREFS_KEY_ALBUM_ART, if (albumArt == null) "null" else "notnull")
}
lastPlayer = playerPackage ?: lastPlayer
this@MusicRepositoryImpl.title.value = title
this@MusicRepositoryImpl.artist.value = artist
this@MusicRepositoryImpl.album.value = album
}
private fun getMusicApps(): Set<String> {
val apps = mutableSetOf<String>()
var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
.map { it.activityInfo.packageName })
intent = Intent("android.intent.action.MUSIC_PLAYER")
apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
.map { it.activityInfo.packageName })
Log.d("MM20", apps.joinToString())
return apps
}
override fun resetPlayer() {
scope.launch {
mediaController?.close()
mediaController = null
setMetadata(null, null, null, null, null)
preferences.edit {
clear()
}
}
}

View File

@ -38,8 +38,6 @@ dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.media2)
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.koin.android)

View File

@ -38,7 +38,6 @@ dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.media2)
implementation(libs.androidx.constraintlayout)
implementation(libs.bundles.androidx.lifecycle)