diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b5cfc9d..8dc04560 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/music/build.gradle.kts b/music/build.gradle.kts index 9d071c9b..9cb44d4e 100644 --- a/music/build.gradle.kts +++ b/music/build.gradle.kts @@ -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")) diff --git a/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt b/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt index 4dfbc9b9..87c44050 100644 --- a/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt +++ b/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt @@ -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 @@ -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(null) - override val artist = MutableStateFlow(null) - override val album = MutableStateFlow(null) - override val albumArt = MutableStateFlow(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 = + 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 = 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 = 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 = 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 = 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 = 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 { + val apps = mutableSetOf() + 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() + } } } diff --git a/notifications/build.gradle.kts b/notifications/build.gradle.kts index 32c69c0c..a8631813 100644 --- a/notifications/build.gradle.kts +++ b/notifications/build.gradle.kts @@ -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) diff --git a/widgets/build.gradle.kts b/widgets/build.gradle.kts index 69849a13..e95dfc88 100644 --- a/widgets/build.gradle.kts +++ b/widgets/build.gradle.kts @@ -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)