diff --git a/i18n/src/main/res/values-de/strings.xml b/i18n/src/main/res/values-de/strings.xml index 0df9825d..1769c4c4 100644 --- a/i18n/src/main/res/values-de/strings.xml +++ b/i18n/src/main/res/values-de/strings.xml @@ -419,7 +419,8 @@ Spaltenanzahl Gewähren - Standortzugriff wird benötigt, um den Standort automatisch zu ermitteln + Standortzugriff wird benötigt um den Standort automatisch zu ermitteln + Benachrichtigungszugriff wird benötigt um Medienwiedergabe zu steuern Standort festlegen Debug @@ -429,7 +430,14 @@ Widgets konfigurieren Wetter + Musik Crash-Reporter Fehler- und Absturzberichte + + Auf Musik-Apps begrenzen + Mediensitzungen von Apps ignorieren, die keine Musik-Apps sind + + %1$s spielt Medien + Bisher wurden keine Medien abgespielt \ No newline at end of file diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index 749ef5b3..4a28e84b 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -458,6 +458,7 @@ Grant Location access is required to determine the location automatically + Notification access is required to control media playback Set location Debug @@ -467,7 +468,15 @@ Configure widgets Weather + Music Crash reporter Error and crash reports + + Restrict to music apps + Ignore media sessions of apps that are not music apps + + %1$s is playing media + + No media has been played yet diff --git a/music/build.gradle.kts b/music/build.gradle.kts index 70c3da3f..e15a99bc 100644 --- a/music/build.gradle.kts +++ b/music/build.gradle.kts @@ -43,5 +43,6 @@ dependencies { implementation(libs.koin.android) implementation(project(":ktx")) + implementation(project(":preferences")) } \ No newline at end of file diff --git a/music/src/main/java/de/mm20/launcher2/music/Module.kt b/music/src/main/java/de/mm20/launcher2/music/Module.kt index 5bf012ec..61e25a4e 100644 --- a/music/src/main/java/de/mm20/launcher2/music/Module.kt +++ b/music/src/main/java/de/mm20/launcher2/music/Module.kt @@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module val musicModule = module { - single { MusicRepository(androidContext()) } + single { MusicRepositoryImpl(androidContext()) } } \ No newline at end of file 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 55006085..9e3a7261 100644 --- a/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt +++ b/music/src/main/java/de/mm20/launcher2/music/MusicRepository.kt @@ -7,7 +7,6 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioManager import android.support.v4.media.session.MediaSessionCompat -import android.util.Log import android.view.KeyEvent import androidx.core.content.edit import androidx.core.graphics.scale @@ -16,39 +15,96 @@ import androidx.media2.common.MediaMetadata import androidx.media2.common.SessionPlayer import androidx.media2.session.MediaController import androidx.media2.session.SessionCommandGroup +import de.mm20.launcher2.preferences.LauncherDataStore import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +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 -class MusicRepository(val context: Context) { +interface MusicRepository { + val playbackState: Flow + val title: Flow + val artist: Flow + val album: Flow + val albumArt: Flow + + fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) + + fun next() + fun previous() + fun pause() + fun play() + fun togglePause() + fun openPlayer(): PendingIntent? + + fun openPlayerChooser(context: Context) + + fun resetPlayer() +} + +class MusicRepositoryImpl(val context: Context) : MusicRepository, KoinComponent { private val scope = CoroutineScope(Job() + Dispatchers.Main) + private val dataStore: LauncherDataStore by inject() - val playbackState = MutableStateFlow(PlaybackState.Stopped) - val title = MutableStateFlow(null) - val artist = MutableStateFlow(null) - val album = MutableStateFlow(null) - val albumArt = MutableStateFlow(null) + 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 - set(value) { - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit { - putString(PREFS_KEY_LAST_PLAYER, value) - } - field = value - } private var lastToken: String? = null - fun setMediaSession(token: MediaSessionCompat.Token) { + private val semaphore = Semaphore(permits = 1) + + override fun setMediaSession(token: MediaSessionCompat.Token, packageName: String) { if (token.toString() == lastToken.toString()) return - mediaController?.close() - mediaController = MediaController.Builder(context) - .setSessionCompatToken(token) - .setControllerCallback(Executors.newSingleThreadExecutor(), mediaSessionCallback) - .build() - lastToken = token.toString() + + scope.launch { + val filterMusicApps = dataStore.data.map { it.musicWidget.filterSources }.first() + if (filterMusicApps) { + val intent = + Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } + if (context.packageManager.queryIntentActivities(intent, 0) + .none { it.activityInfo.packageName == packageName } + ) { + return@launch + } + } + + 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() + semaphore.release() + + } } private var mediaController: MediaController? = null @@ -65,91 +121,52 @@ class MusicRepository(val context: Context) { allowedCommands: SessionCommandGroup ) { super.onConnected(controller, allowedCommands) - updateMetadata() - updateState() + if (!controller.isConnected) return + updateMetadata(controller.currentMediaItem, controller.connectedToken?.packageName) + updateState(controller.playerState) } override fun onCurrentMediaItemChanged(controller: MediaController, item: MediaItem?) { super.onCurrentMediaItemChanged(controller, item) - updateMetadata() + if (!controller.isConnected) return + updateMetadata(item, controller.connectedToken?.packageName) } override fun onPlayerStateChanged(controller: MediaController, state: Int) { super.onPlayerStateChanged(controller, state) - updateState() - } - - override fun onPlaybackInfoChanged( - controller: MediaController, - info: MediaController.PlaybackInfo - ) { - super.onPlaybackInfoChanged(controller, info) - Log.d("MM20", "CurrentPosition" + controller.currentPosition.toString()) + if (!controller.isConnected) return + updateState(state) } override fun onDisconnected(controller: MediaController) { super.onDisconnected(controller) mediaController = null } - - /*override fun onMetadataChanged(metadata: MediaController?) { - super.onMetadataChanged(metadata) - updateState() - } - - override fun onSessionDestroyed() { - super.onSessionDestroyed() - mediaController = null - hasActiveSession.value = false - playbackState.value = PlaybackState.Stopped - }*/ } - private fun updateMetadata() { - val metadata = mediaController?.currentMediaItem?.metadata ?: return + 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) - val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST) + ?: 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) - lastPlayer = mediaController?.connectedToken?.packageName ?: lastPlayer - this@MusicRepository.title.value = title - this@MusicRepository.artist.value = artist - this@MusicRepository.album.value = album + // Hack for Spotify sending inconsistent metadata updates + if (playerPackage == "com.spotify.music" && album == null) return scope.launch { - withContext(Dispatchers.IO) { - val albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) - context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit { - putString(PREFS_KEY_ALBUM_ART, if (albumArt == null) "null" else "notnull") - } - if (albumArt == null) { - this@MusicRepository.albumArt.value = null - return@withContext - } - val size = context.resources.getDimensionPixelSize(R.dimen.album_art_size) - val scaledBitmap = albumArt.scale(size, size) - val file = File(context.cacheDir, "album_art") - val outStream = file.outputStream() - scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, outStream) - outStream.close() - this@MusicRepository.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) + setMetadata(title, artist, album, albumArt, playerPackage) } } - private fun updateState() { - val playbackState = when (mediaController?.playerState) { + private fun updateState(playerState: Int) { + val playbackState = when (playerState) { SessionPlayer.PLAYER_STATE_PLAYING -> PlaybackState.Playing SessionPlayer.PLAYER_STATE_PAUSED -> PlaybackState.Paused else -> PlaybackState.Stopped @@ -157,8 +174,6 @@ class MusicRepository(val context: Context) { this.playbackState.value = playbackState } - val hasActiveSession: Boolean = mediaController?.isConnected != null - init { loadLastPlaybackMetadata() } @@ -176,12 +191,12 @@ class MusicRepository(val context: Context) { val albumArt = withContext(Dispatchers.IO) { BitmapFactory.decodeFile(File(context.cacheDir, "album_art").absolutePath) } - this@MusicRepository.albumArt.value = albumArt + this@MusicRepositoryImpl.albumArt.value = albumArt } playbackState.value = PlaybackState.Stopped } - fun previous() { + 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) @@ -191,7 +206,7 @@ class MusicRepository(val context: Context) { } } - fun next() { + 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) @@ -201,7 +216,7 @@ class MusicRepository(val context: Context) { } } - fun play() { + 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) @@ -211,7 +226,7 @@ class MusicRepository(val context: Context) { } } - fun pause() { + 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) @@ -221,18 +236,24 @@ class MusicRepository(val context: Context) { } } - fun togglePause() { + override fun togglePause() { if (playbackState.value != PlaybackState.Playing) play() else pause() } - fun getLaunchIntent(): PendingIntent { + override fun openPlayer(): PendingIntent? { mediaController?.sessionActivity?.let { return it } - val intent = Intent(Intent.ACTION_MAIN) - .setPackage(lastPlayer) - .addCategory(Intent.CATEGORY_APP_MUSIC) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + val intent = lastPlayer?.let { + context.packageManager.getLaunchIntentForPackage(it)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } ?: return null + + if (context.packageManager.resolveActivity(intent, 0) == null) { + return null + } return PendingIntent.getActivity( context, @@ -242,7 +263,7 @@ class MusicRepository(val context: Context) { ) } - fun openPlayerChooser(context: Context) { + override fun openPlayerChooser(context: Context) { context.startActivity( Intent.createChooser( Intent(Intent.ACTION_MAIN) @@ -255,6 +276,51 @@ class MusicRepository(val context: Context) { ) } + 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.getDimensionPixelSize(R.dimen.album_art_size) + val scaledBitmap = albumArt.scale(size, size) + 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 + } + } + + override fun resetPlayer() { + scope.launch { + mediaController?.close() + mediaController = null + setMetadata(null, null, null, null, null) + } + } + companion object { private const val PREFS = "music" diff --git a/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt index 1a0073a2..50c176d4 100644 --- a/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt +++ b/notifications/src/main/java/de/mm20/launcher2/notifications/NotificationService.kt @@ -34,13 +34,13 @@ class NotificationService : NotificationListenerService() { Log.d("MM20", "Notification listener connected") permissionsManager.reportNotificationListenerState(true) instance = WeakReference(this) - val notifications = getNotifications().sortedByDescending { it.postTime } + val notifications = getNotifications().sortedBy { it.postTime } for (n in notifications) { - val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } - if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == n.packageName }) continue + /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } + if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == n.packageName }) continue*/ val token = n.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token ?: continue - musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token)) + musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), n.packageName) } if (LauncherPreferences.instance.notificationBadges) { generateBadges() @@ -79,11 +79,11 @@ class NotificationService : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification) { if (sbn.notification.category == Notification.CATEGORY_TRANSPORT || sbn.notification.category == Notification.CATEGORY_SERVICE) { - val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } - if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == sbn.packageName }) return + /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } + if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == sbn.packageName }) return*/ val token = sbn.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token ?: return - musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token)) + musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), sbn.packageName) } if (LauncherPreferences.instance.notificationBadges) { val pkg = sbn.packageName diff --git a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt index f243a087..2b09b9ca 100644 --- a/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt +++ b/preferences/src/main/java/de/mm20/launcher2/preferences/Defaults.kt @@ -16,5 +16,10 @@ fun createFactorySettings(context: Context): Settings { .setImperialUnits(context.resources.getBoolean(R.bool.default_imperialUnits)) .build() ) + .setMusicWidget(Settings.MusicWidgetSettings + .newBuilder() + .setFilterSources(true) + .build() + ) .build() } \ No newline at end of file diff --git a/preferences/src/main/proto/settings.proto b/preferences/src/main/proto/settings.proto index 81b08a27..169f294f 100644 --- a/preferences/src/main/proto/settings.proto +++ b/preferences/src/main/proto/settings.proto @@ -32,4 +32,9 @@ message Settings { } WeatherSettings weather = 5; + message MusicWidgetSettings { + bool filter_sources = 1; + } + MusicWidgetSettings music_widget = 6; + } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt index 88b9ce4f..41bf4402 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidget.kt @@ -1,5 +1,7 @@ -package de.mm20.launcher2.ui.widget +package de.mm20.launcher2.ui.launcher.widgets.music +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter @@ -10,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Audiotrack import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipPrevious @@ -24,13 +27,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import de.mm20.launcher2.music.PlaybackState import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.ktx.conditional -import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidgetVM @OptIn( ExperimentalFoundationApi::class, @@ -49,107 +53,152 @@ fun MusicWidget() { val context = LocalContext.current - Row( - modifier = Modifier.height(IntrinsicSize.Min) + Column( + modifier = Modifier.fillMaxWidth() ) { - Column( - modifier = Modifier - .padding(top = 16.dp) - .fillMaxHeight() - .weight(2f), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp) - ) { - Text( - text = title ?: "---", - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = artist ?: "---", - modifier = Modifier.padding(vertical = 2.dp), - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = album ?: "---", - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp, end = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - IconButton( - onClick = { - viewModel.skipPrevious() - }) { - Icon( - imageVector = Icons.Rounded.SkipPrevious, - null - ) + val hasPermission by viewModel.hasPermission.observeAsState() + AnimatedVisibility(hasPermission == false) { + MissingPermissionBanner( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.missing_permission_music_widget), + onClick = { + viewModel.requestPermission(context as AppCompatActivity) } - val playPauseIcon = AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_play_pause) - IconButton(onClick = { viewModel.togglePause() }) { - Icon( - painter = rememberAnimatedVectorPainter(playPauseIcon, atEnd = playbackState == PlaybackState.Playing), - contentDescription = "" - ) - } - IconButton(onClick = { - viewModel.skipNext() - }) { - Icon( - imageVector = Icons.Rounded.SkipNext, - null - ) - } - } + ) } - Box( - modifier = Modifier - .size(144.dp) - .combinedClickable( - onClick = { - viewModel.openPlayer() - }, - onLongClick = { - viewModel.openPlayerSelector(context) + if (title == null && artist == null && album == null) { + NoData() + } else { + Row( + modifier = Modifier.height(IntrinsicSize.Min) + ) { + Column( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxHeight() + .weight(2f), + verticalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + text = title ?: "", + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = artist ?: "", + modifier = Modifier.padding(vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = album ?: "", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - ) - .conditional( - albumArt == null, - Modifier.background( - MaterialTheme.colorScheme.primaryContainer, - ) - ), - contentAlignment = Alignment.Center - ) { - if (albumArt != null) { - albumArt?.let { - Image( - bitmap = it.asImageBitmap(), + Row( modifier = Modifier - .fillMaxSize(), - contentDescription = null - ) + .fillMaxWidth() + .padding(bottom = 4.dp, end = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = { + viewModel.skipPrevious() + }) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + null + ) + } + val playPauseIcon = + AnimatedImageVector.animatedVectorResource(R.drawable.anim_ic_play_pause) + IconButton(onClick = { viewModel.togglePause() }) { + Icon( + painter = rememberAnimatedVectorPainter( + playPauseIcon, + atEnd = playbackState == PlaybackState.Playing + ), + contentDescription = "" + ) + } + IconButton(onClick = { + viewModel.skipNext() + }) { + Icon( + imageVector = Icons.Rounded.SkipNext, + null + ) + } + } + } + Box( + modifier = Modifier + .size(144.dp) + .combinedClickable( + onClick = { + viewModel.openPlayer() + }, + onLongClick = { + viewModel.openPlayerSelector(context) + } + ) + .conditional( + albumArt == null, + Modifier.background( + MaterialTheme.colorScheme.primaryContainer, + ) + ), + contentAlignment = Alignment.Center + ) { + if (albumArt != null) { + albumArt?.let { + Image( + bitmap = it.asImageBitmap(), + modifier = Modifier + .fillMaxSize(), + contentDescription = null + ) + } + } else { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp) + ) + } } - } else { - Icon( - imageVector = Icons.Rounded.MusicNote, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(56.dp) - ) } } } +} + +@Composable +fun NoData() { + Row( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Audiotrack, + contentDescription = "", + modifier = Modifier + .padding(24.dp) + .size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Text( + text = stringResource(id = R.string.music_widget_no_data), + style = MaterialTheme.typography.bodySmall + ) + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidgetVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidgetVM.kt index 629f32d4..9ea6ceac 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidgetVM.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/launcher/widgets/music/MusicWidgetVM.kt @@ -3,17 +3,21 @@ package de.mm20.launcher2.ui.launcher.widgets.music import android.app.PendingIntent import android.content.Context import android.graphics.Bitmap +import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.music.MusicRepository import de.mm20.launcher2.music.PlaybackState +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager import org.koin.core.component.KoinComponent import org.koin.core.component.inject class MusicWidgetVM: ViewModel(), KoinComponent { private val musicRepository: MusicRepository by inject() + private val permissionsManager: PermissionsManager by inject() val title: LiveData = musicRepository.title.asLiveData() val artist: LiveData = musicRepository.artist.asLiveData() @@ -21,6 +25,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent { val albumArt: LiveData = musicRepository.albumArt.asLiveData() val playbackState: LiveData = musicRepository.playbackState.asLiveData() + val hasPermission = permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData() + fun skipPrevious() { musicRepository.previous() } @@ -35,7 +41,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent { fun openPlayer() { try { - musicRepository.getLaunchIntent().send() + musicRepository. + openPlayer()?.send() } catch (e: PendingIntent.CanceledException) { CrashReporter.logException(e) } @@ -44,4 +51,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent { fun openPlayerSelector(context: Context) { musicRepository.openPlayerChooser(context) } + + fun requestPermission(context: AppCompatActivity) { + permissionsManager.requestPermission(context, PermissionGroup.Notifications) + } } \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/legacy/widget/MusicWidget.kt b/ui/src/main/java/de/mm20/launcher2/ui/legacy/widget/MusicWidget.kt index 9ed562f1..ccb27044 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/legacy/widget/MusicWidget.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/legacy/widget/MusicWidget.kt @@ -10,7 +10,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.ComposeView import de.mm20.launcher2.ui.LegacyLauncherTheme import de.mm20.launcher2.ui.R -import de.mm20.launcher2.ui.widget.MusicWidget +import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget class MusicWidget : LauncherWidget { diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt index b720ca40..e23ad411 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/SettingsActivity.kt @@ -26,6 +26,7 @@ import de.mm20.launcher2.ui.settings.appearance.AppearanceSettingsScreen import de.mm20.launcher2.ui.settings.debug.DebugSettingsScreen import de.mm20.launcher2.ui.settings.license.LicenseScreen import de.mm20.launcher2.ui.settings.main.MainSettingsScreen +import de.mm20.launcher2.ui.settings.musicwidget.MusicWidgetSettingsScreen import de.mm20.launcher2.ui.settings.weatherwidget.WeatherWidgetSettingsScreen import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen @@ -89,6 +90,9 @@ class SettingsActivity : BaseActivity() { composable("settings/widgets/weather") { WeatherWidgetSettingsScreen() } + composable("settings/widgets/music") { + MusicWidgetSettingsScreen() + } composable("settings/about") { AboutSettingsScreen() } diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreen.kt new file mode 100644 index 00000000..5b4e1074 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreen.kt @@ -0,0 +1,66 @@ +package de.mm20.launcher2.ui.settings.musicwidget + +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import de.mm20.launcher2.ui.BuildConfig +import de.mm20.launcher2.ui.R +import de.mm20.launcher2.ui.component.MissingPermissionBanner +import de.mm20.launcher2.ui.component.preferences.Preference +import de.mm20.launcher2.ui.component.preferences.PreferenceCategory +import de.mm20.launcher2.ui.component.preferences.PreferenceScreen +import de.mm20.launcher2.ui.component.preferences.SwitchPreference + +@Composable +fun MusicWidgetSettingsScreen() { + val context = LocalContext.current + val viewModel: MusicWidgetSettingsScreenVM = viewModel() + val hasPermission by viewModel.hasPermission.observeAsState() + PreferenceScreen( + stringResource(R.string.preference_screen_musicwidget) + ) { + item { + PreferenceCategory { + AnimatedVisibility(hasPermission == false) { + MissingPermissionBanner( + text = stringResource(R.string.missing_permission_music_widget), + onClick = { + viewModel.requestNotificationPermission(context as AppCompatActivity) + }, + modifier = Modifier.padding(16.dp) + ) + } + val filterSources by viewModel.filterSources.observeAsState(false) + SwitchPreference( + title = stringResource(R.string.preference_music_filter_sources), + summary = stringResource(R.string.preference_music_filter_sources_summary), + value = filterSources, + onValueChanged = { + viewModel.setFilterSources(it) + } + ) + } + } + if (BuildConfig.DEBUG) { + item { + PreferenceCategory(stringResource(R.string.preference_category_debug)) { + Preference( + title = "Reset widget", + summary = "Clear all music data", + onClick = { + viewModel.resetWidget() + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreenVM.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreenVM.kt new file mode 100644 index 00000000..3e1c0483 --- /dev/null +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/musicwidget/MusicWidgetSettingsScreenVM.kt @@ -0,0 +1,46 @@ +package de.mm20.launcher2.ui.settings.musicwidget + +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import de.mm20.launcher2.music.MusicRepository +import de.mm20.launcher2.permissions.PermissionGroup +import de.mm20.launcher2.permissions.PermissionsManager +import de.mm20.launcher2.preferences.LauncherDataStore +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class MusicWidgetSettingsScreenVM : ViewModel(), KoinComponent { + private val permissionsManager: PermissionsManager by inject() + private val musicRepository: MusicRepository by inject() + private val dataStore: LauncherDataStore by inject() + val hasPermission = + permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData() + + + fun requestNotificationPermission(activity: AppCompatActivity) { + permissionsManager.requestPermission(activity, PermissionGroup.Notifications) + } + + val filterSources = dataStore.data.map { it.musicWidget.filterSources }.asLiveData() + fun setFilterSources(filterSources: Boolean) { + viewModelScope.launch { + dataStore.updateData { + it.toBuilder() + .setMusicWidget( + it.musicWidget.toBuilder() + .setFilterSources(filterSources) + ) + .build() + } + } + } + + fun resetWidget() { + musicRepository.resetPlayer() + } + +} \ No newline at end of file diff --git a/ui/src/main/java/de/mm20/launcher2/ui/settings/widgets/WidgetsSettingsScreen.kt b/ui/src/main/java/de/mm20/launcher2/ui/settings/widgets/WidgetsSettingsScreen.kt index adcfc723..bcc42009 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/settings/widgets/WidgetsSettingsScreen.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/settings/widgets/WidgetsSettingsScreen.kt @@ -1,6 +1,7 @@ package de.mm20.launcher2.ui.settings.widgets import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Audiotrack import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.Today import androidx.compose.runtime.Composable @@ -22,6 +23,13 @@ fun WidgetsSettingsScreen() { navController?.navigate("settings/widgets/weather") } ) + Preference( + title = stringResource(R.string.preference_screen_musicwidget), + icon = Icons.Rounded.Audiotrack, + onClick = { + navController?.navigate("settings/widgets/music") + } + ) Preference( title = stringResource(R.string.preference_screen_calendarwidget), icon = Icons.Rounded.Today diff --git a/ui/src/main/java/de/mm20/launcher2/ui/widget/WidgetCard.kt b/ui/src/main/java/de/mm20/launcher2/ui/widget/WidgetCard.kt index 12a8f337..e800364a 100644 --- a/ui/src/main/java/de/mm20/launcher2/ui/widget/WidgetCard.kt +++ b/ui/src/main/java/de/mm20/launcher2/ui/widget/WidgetCard.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidget import de.mm20.launcher2.ui.launcher.widgets.weather.WeatherWidget import de.mm20.launcher2.widgets.Widget import de.mm20.launcher2.widgets.WidgetType