Add option to disable music widget filtering

This commit is contained in:
MM20 2022-01-06 23:43:50 +01:00
parent 51e9370dd5
commit d3358fa7b5
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
16 changed files with 477 additions and 198 deletions

View File

@ -419,7 +419,8 @@
<string name="preference_grid_column_count">Spaltenanzahl</string> <string name="preference_grid_column_count">Spaltenanzahl</string>
<string name="grant_permission">Gewähren</string> <string name="grant_permission">Gewähren</string>
<string name="missing_permission_auto_location">Standortzugriff wird benötigt, um den Standort automatisch zu ermitteln</string> <string name="missing_permission_auto_location">Standortzugriff wird benötigt um den Standort automatisch zu ermitteln</string>
<string name="missing_permission_music_widget">Benachrichtigungszugriff wird benötigt um Medienwiedergabe zu steuern</string>
<string name="weather_widget_set_location">Standort festlegen</string> <string name="weather_widget_set_location">Standort festlegen</string>
<string name="preference_screen_debug">Debug</string> <string name="preference_screen_debug">Debug</string>
@ -429,7 +430,14 @@
<string name="preference_screen_widgets_summary">Widgets konfigurieren</string> <string name="preference_screen_widgets_summary">Widgets konfigurieren</string>
<string name="preference_screen_weatherwidget">Wetter</string> <string name="preference_screen_weatherwidget">Wetter</string>
<string name="preference_screen_musicwidget">Musik</string>
<string name="preference_crash_reporter">Crash-Reporter</string> <string name="preference_crash_reporter">Crash-Reporter</string>
<string name="preference_crash_reporter_summary">Fehler- und Absturzberichte</string> <string name="preference_crash_reporter_summary">Fehler- und Absturzberichte</string>
<string name="preference_music_filter_sources">Auf Musik-Apps begrenzen</string>
<string name="preference_music_filter_sources_summary">Mediensitzungen von Apps ignorieren, die keine Musik-Apps sind</string>
<string name="music_widget_default_title">%1$s spielt Medien</string>
<string name="music_widget_no_data">Bisher wurden keine Medien abgespielt</string>
</resources> </resources>

View File

@ -458,6 +458,7 @@
<string name="grant_permission">Grant</string> <string name="grant_permission">Grant</string>
<string name="missing_permission_auto_location">Location access is required to determine the location automatically</string> <string name="missing_permission_auto_location">Location access is required to determine the location automatically</string>
<string name="missing_permission_music_widget">Notification access is required to control media playback</string>
<string name="weather_widget_set_location">Set location</string> <string name="weather_widget_set_location">Set location</string>
<string name="preference_screen_debug">Debug</string> <string name="preference_screen_debug">Debug</string>
@ -467,7 +468,15 @@
<string name="preference_screen_widgets_summary">Configure widgets</string> <string name="preference_screen_widgets_summary">Configure widgets</string>
<string name="preference_screen_weatherwidget">Weather</string> <string name="preference_screen_weatherwidget">Weather</string>
<string name="preference_screen_musicwidget">Music</string>
<string name="preference_crash_reporter">Crash reporter</string> <string name="preference_crash_reporter">Crash reporter</string>
<string name="preference_crash_reporter_summary">Error and crash reports</string> <string name="preference_crash_reporter_summary">Error and crash reports</string>
<string name="preference_music_filter_sources">Restrict to music apps</string>
<string name="preference_music_filter_sources_summary">Ignore media sessions of apps that are not music apps</string>
<string name="music_widget_default_title">%1$s is playing media</string>
<string name="music_widget_no_data">No media has been played yet</string>
</resources> </resources>

View File

@ -43,5 +43,6 @@ dependencies {
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(project(":ktx")) implementation(project(":ktx"))
implementation(project(":preferences"))
} }

View File

@ -5,5 +5,5 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val musicModule = module { val musicModule = module {
single { MusicRepository(androidContext()) } single<MusicRepository> { MusicRepositoryImpl(androidContext()) }
} }

View File

@ -7,7 +7,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.AudioManager import android.media.AudioManager
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.scale import androidx.core.graphics.scale
@ -16,39 +15,96 @@ import androidx.media2.common.MediaMetadata
import androidx.media2.common.SessionPlayer import androidx.media2.common.SessionPlayer
import androidx.media2.session.MediaController import androidx.media2.session.MediaController
import androidx.media2.session.SessionCommandGroup import androidx.media2.session.SessionCommandGroup
import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow 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.io.File
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MusicRepository(val context: Context) { interface MusicRepository {
val playbackState: Flow<PlaybackState>
val title: Flow<String?>
val artist: Flow<String?>
val album: Flow<String?>
val albumArt: Flow<Bitmap?>
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 scope = CoroutineScope(Job() + Dispatchers.Main)
private val dataStore: LauncherDataStore by inject()
val playbackState = MutableStateFlow(PlaybackState.Stopped) override val playbackState = MutableStateFlow(PlaybackState.Stopped)
val title = MutableStateFlow<String?>(null) override val title = MutableStateFlow<String?>(null)
val artist = MutableStateFlow<String?>(null) override val artist = MutableStateFlow<String?>(null)
val album = MutableStateFlow<String?>(null) override val album = MutableStateFlow<String?>(null)
val albumArt = MutableStateFlow<Bitmap?>(null) override val albumArt = MutableStateFlow<Bitmap?>(null)
private var lastPlayer: String? = 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 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 if (token.toString() == lastToken.toString()) return
mediaController?.close()
mediaController = MediaController.Builder(context) scope.launch {
.setSessionCompatToken(token) val filterMusicApps = dataStore.data.map { it.musicWidget.filterSources }.first()
.setControllerCallback(Executors.newSingleThreadExecutor(), mediaSessionCallback) if (filterMusicApps) {
.build() val intent =
lastToken = token.toString() 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 private var mediaController: MediaController? = null
@ -65,91 +121,52 @@ class MusicRepository(val context: Context) {
allowedCommands: SessionCommandGroup allowedCommands: SessionCommandGroup
) { ) {
super.onConnected(controller, allowedCommands) super.onConnected(controller, allowedCommands)
updateMetadata() if (!controller.isConnected) return
updateState() updateMetadata(controller.currentMediaItem, controller.connectedToken?.packageName)
updateState(controller.playerState)
} }
override fun onCurrentMediaItemChanged(controller: MediaController, item: MediaItem?) { override fun onCurrentMediaItemChanged(controller: MediaController, item: MediaItem?) {
super.onCurrentMediaItemChanged(controller, item) super.onCurrentMediaItemChanged(controller, item)
updateMetadata() if (!controller.isConnected) return
updateMetadata(item, controller.connectedToken?.packageName)
} }
override fun onPlayerStateChanged(controller: MediaController, state: Int) { override fun onPlayerStateChanged(controller: MediaController, state: Int) {
super.onPlayerStateChanged(controller, state) super.onPlayerStateChanged(controller, state)
updateState() if (!controller.isConnected) return
} updateState(state)
override fun onPlaybackInfoChanged(
controller: MediaController,
info: MediaController.PlaybackInfo
) {
super.onPlaybackInfoChanged(controller, info)
Log.d("MM20", "CurrentPosition" + controller.currentPosition.toString())
} }
override fun onDisconnected(controller: MediaController) { override fun onDisconnected(controller: MediaController) {
super.onDisconnected(controller) super.onDisconnected(controller)
mediaController = null 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() { private fun updateMetadata(mediaItem: MediaItem?, playerPackage: String?) {
val metadata = mediaController?.currentMediaItem?.metadata ?: return val metadata = mediaItem?.metadata ?: return
val title = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE) val title = metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE)
?: metadata.getString(MediaMetadata.METADATA_KEY_TITLE) ?: metadata.getString(MediaMetadata.METADATA_KEY_TITLE) ?: return
val artist = metadata.getString(MediaMetadata.METADATA_KEY_ARTIST) 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_COMPOSER)
?: metadata.getString(MediaMetadata.METADATA_KEY_AUTHOR) ?: metadata.getString(MediaMetadata.METADATA_KEY_AUTHOR)
?: metadata.getString(MediaMetadata.METADATA_KEY_WRITER) ?: metadata.getString(MediaMetadata.METADATA_KEY_WRITER)
?: return ?: return
val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM) val album = metadata.getString(MediaMetadata.METADATA_KEY_ALBUM)
val albumArt = metadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART)
lastPlayer = mediaController?.connectedToken?.packageName ?: lastPlayer // Hack for Spotify sending inconsistent metadata updates
this@MusicRepository.title.value = title if (playerPackage == "com.spotify.music" && album == null) return
this@MusicRepository.artist.value = artist
this@MusicRepository.album.value = album
scope.launch { scope.launch {
withContext(Dispatchers.IO) { setMetadata(title, artist, album, albumArt, playerPackage)
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)
} }
} }
private fun updateState() { private fun updateState(playerState: Int) {
val playbackState = when (mediaController?.playerState) { val playbackState = when (playerState) {
SessionPlayer.PLAYER_STATE_PLAYING -> PlaybackState.Playing SessionPlayer.PLAYER_STATE_PLAYING -> PlaybackState.Playing
SessionPlayer.PLAYER_STATE_PAUSED -> PlaybackState.Paused SessionPlayer.PLAYER_STATE_PAUSED -> PlaybackState.Paused
else -> PlaybackState.Stopped else -> PlaybackState.Stopped
@ -157,8 +174,6 @@ class MusicRepository(val context: Context) {
this.playbackState.value = playbackState this.playbackState.value = playbackState
} }
val hasActiveSession: Boolean = mediaController?.isConnected != null
init { init {
loadLastPlaybackMetadata() loadLastPlaybackMetadata()
} }
@ -176,12 +191,12 @@ class MusicRepository(val context: Context) {
val albumArt = withContext(Dispatchers.IO) { val albumArt = withContext(Dispatchers.IO) {
BitmapFactory.decodeFile(File(context.cacheDir, "album_art").absolutePath) BitmapFactory.decodeFile(File(context.cacheDir, "album_art").absolutePath)
} }
this@MusicRepository.albumArt.value = albumArt this@MusicRepositoryImpl.albumArt.value = albumArt
} }
playbackState.value = PlaybackState.Stopped playbackState.value = PlaybackState.Stopped
} }
fun previous() { override fun previous() {
if (mediaController?.skipToPreviousPlaylistItem()?.get() == null) { if (mediaController?.skipToPreviousPlaylistItem()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS) 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) { if (mediaController?.skipToNextPlaylistItem()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT) 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) { if (mediaController?.play()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY) 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) { if (mediaController?.pause()?.get() == null) {
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE) 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() if (playbackState.value != PlaybackState.Playing) play() else pause()
} }
fun getLaunchIntent(): PendingIntent { override fun openPlayer(): PendingIntent? {
mediaController?.sessionActivity?.let { mediaController?.sessionActivity?.let {
return it return it
} }
val intent = Intent(Intent.ACTION_MAIN)
.setPackage(lastPlayer) val intent = lastPlayer?.let {
.addCategory(Intent.CATEGORY_APP_MUSIC) context.packageManager.getLaunchIntentForPackage(it)?.apply {
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} ?: return null
if (context.packageManager.resolveActivity(intent, 0) == null) {
return null
}
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, context,
@ -242,7 +263,7 @@ class MusicRepository(val context: Context) {
) )
} }
fun openPlayerChooser(context: Context) { override fun openPlayerChooser(context: Context) {
context.startActivity( context.startActivity(
Intent.createChooser( Intent.createChooser(
Intent(Intent.ACTION_MAIN) 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 { companion object {
private const val PREFS = "music" private const val PREFS = "music"

View File

@ -34,13 +34,13 @@ class NotificationService : NotificationListenerService() {
Log.d("MM20", "Notification listener connected") Log.d("MM20", "Notification listener connected")
permissionsManager.reportNotificationListenerState(true) permissionsManager.reportNotificationListenerState(true)
instance = WeakReference(this) instance = WeakReference(this)
val notifications = getNotifications().sortedByDescending { it.postTime } val notifications = getNotifications().sortedBy { it.postTime }
for (n in notifications) { for (n in notifications) {
val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) } /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == n.packageName }) continue if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == n.packageName }) continue*/
val token = n.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token val token = n.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token
?: continue ?: continue
musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token)) musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), n.packageName)
} }
if (LauncherPreferences.instance.notificationBadges) { if (LauncherPreferences.instance.notificationBadges) {
generateBadges() generateBadges()
@ -79,11 +79,11 @@ class NotificationService : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
if (sbn.notification.category == Notification.CATEGORY_TRANSPORT || sbn.notification.category == Notification.CATEGORY_SERVICE) { 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) } /*val intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == sbn.packageName }) return if (packageManager.queryIntentActivities(intent, 0).none { it.activityInfo.packageName == sbn.packageName }) return*/
val token = sbn.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token val token = sbn.notification.extras[NotificationCompat.EXTRA_MEDIA_SESSION] as? MediaSession.Token
?: return ?: return
musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token)) musicRepository.setMediaSession(MediaSessionCompat.Token.fromToken(token), sbn.packageName)
} }
if (LauncherPreferences.instance.notificationBadges) { if (LauncherPreferences.instance.notificationBadges) {
val pkg = sbn.packageName val pkg = sbn.packageName

View File

@ -16,5 +16,10 @@ fun createFactorySettings(context: Context): Settings {
.setImperialUnits(context.resources.getBoolean(R.bool.default_imperialUnits)) .setImperialUnits(context.resources.getBoolean(R.bool.default_imperialUnits))
.build() .build()
) )
.setMusicWidget(Settings.MusicWidgetSettings
.newBuilder()
.setFilterSources(true)
.build()
)
.build() .build()
} }

View File

@ -32,4 +32,9 @@ message Settings {
} }
WeatherSettings weather = 5; WeatherSettings weather = 5;
message MusicWidgetSettings {
bool filter_sources = 1;
}
MusicWidgetSettings music_widget = 6;
} }

View File

@ -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.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter 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.combinedClickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons 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.MusicNote
import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.material.icons.rounded.SkipNext
import androidx.compose.material.icons.rounded.SkipPrevious 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.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.music.PlaybackState import de.mm20.launcher2.music.PlaybackState
import de.mm20.launcher2.ui.R 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.ktx.conditional
import de.mm20.launcher2.ui.launcher.widgets.music.MusicWidgetVM
@OptIn( @OptIn(
ExperimentalFoundationApi::class, ExperimentalFoundationApi::class,
@ -49,107 +53,152 @@ fun MusicWidget() {
val context = LocalContext.current val context = LocalContext.current
Row( Column(
modifier = Modifier.height(IntrinsicSize.Min) modifier = Modifier.fillMaxWidth()
) { ) {
Column( val hasPermission by viewModel.hasPermission.observeAsState()
modifier = Modifier AnimatedVisibility(hasPermission == false) {
.padding(top = 16.dp) MissingPermissionBanner(
.fillMaxHeight() modifier = Modifier.padding(16.dp),
.weight(2f), text = stringResource(R.string.missing_permission_music_widget),
verticalArrangement = Arrangement.SpaceBetween onClick = {
) { viewModel.requestPermission(context as AppCompatActivity)
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 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( if (title == null && artist == null && album == null) {
modifier = Modifier NoData()
.size(144.dp) } else {
.combinedClickable( Row(
onClick = { modifier = Modifier.height(IntrinsicSize.Min)
viewModel.openPlayer() ) {
}, Column(
onLongClick = { modifier = Modifier
viewModel.openPlayerSelector(context) .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(
.conditional(
albumArt == null,
Modifier.background(
MaterialTheme.colorScheme.primaryContainer,
)
),
contentAlignment = Alignment.Center
) {
if (albumArt != null) {
albumArt?.let {
Image(
bitmap = it.asImageBitmap(),
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxWidth()
contentDescription = null .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
)
}
} }

View File

@ -3,17 +3,21 @@ package de.mm20.launcher2.ui.launcher.widgets.music
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.music.MusicRepository import de.mm20.launcher2.music.MusicRepository
import de.mm20.launcher2.music.PlaybackState 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.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
class MusicWidgetVM: ViewModel(), KoinComponent { class MusicWidgetVM: ViewModel(), KoinComponent {
private val musicRepository: MusicRepository by inject() private val musicRepository: MusicRepository by inject()
private val permissionsManager: PermissionsManager by inject()
val title: LiveData<String?> = musicRepository.title.asLiveData() val title: LiveData<String?> = musicRepository.title.asLiveData()
val artist: LiveData<String?> = musicRepository.artist.asLiveData() val artist: LiveData<String?> = musicRepository.artist.asLiveData()
@ -21,6 +25,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
val albumArt: LiveData<Bitmap?> = musicRepository.albumArt.asLiveData() val albumArt: LiveData<Bitmap?> = musicRepository.albumArt.asLiveData()
val playbackState: LiveData<PlaybackState> = musicRepository.playbackState.asLiveData() val playbackState: LiveData<PlaybackState> = musicRepository.playbackState.asLiveData()
val hasPermission = permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData()
fun skipPrevious() { fun skipPrevious() {
musicRepository.previous() musicRepository.previous()
} }
@ -35,7 +41,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
fun openPlayer() { fun openPlayer() {
try { try {
musicRepository.getLaunchIntent().send() musicRepository.
openPlayer()?.send()
} catch (e: PendingIntent.CanceledException) { } catch (e: PendingIntent.CanceledException) {
CrashReporter.logException(e) CrashReporter.logException(e)
} }
@ -44,4 +51,8 @@ class MusicWidgetVM: ViewModel(), KoinComponent {
fun openPlayerSelector(context: Context) { fun openPlayerSelector(context: Context) {
musicRepository.openPlayerChooser(context) musicRepository.openPlayerChooser(context)
} }
fun requestPermission(context: AppCompatActivity) {
permissionsManager.requestPermission(context, PermissionGroup.Notifications)
}
} }

View File

@ -10,7 +10,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import de.mm20.launcher2.ui.LegacyLauncherTheme import de.mm20.launcher2.ui.LegacyLauncherTheme
import de.mm20.launcher2.ui.R 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 { class MusicWidget : LauncherWidget {

View File

@ -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.debug.DebugSettingsScreen
import de.mm20.launcher2.ui.settings.license.LicenseScreen import de.mm20.launcher2.ui.settings.license.LicenseScreen
import de.mm20.launcher2.ui.settings.main.MainSettingsScreen 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.weatherwidget.WeatherWidgetSettingsScreen
import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen import de.mm20.launcher2.ui.settings.widgets.WidgetsSettingsScreen
@ -89,6 +90,9 @@ class SettingsActivity : BaseActivity() {
composable("settings/widgets/weather") { composable("settings/widgets/weather") {
WeatherWidgetSettingsScreen() WeatherWidgetSettingsScreen()
} }
composable("settings/widgets/music") {
MusicWidgetSettingsScreen()
}
composable("settings/about") { composable("settings/about") {
AboutSettingsScreen() AboutSettingsScreen()
} }

View File

@ -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()
}
)
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -1,6 +1,7 @@
package de.mm20.launcher2.ui.settings.widgets package de.mm20.launcher2.ui.settings.widgets
import androidx.compose.material.icons.Icons 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.LightMode
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -22,6 +23,13 @@ fun WidgetsSettingsScreen() {
navController?.navigate("settings/widgets/weather") navController?.navigate("settings/widgets/weather")
} }
) )
Preference(
title = stringResource(R.string.preference_screen_musicwidget),
icon = Icons.Rounded.Audiotrack,
onClick = {
navController?.navigate("settings/widgets/music")
}
)
Preference( Preference(
title = stringResource(R.string.preference_screen_calendarwidget), title = stringResource(R.string.preference_screen_calendarwidget),
icon = Icons.Rounded.Today icon = Icons.Rounded.Today

View File

@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex 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.ui.launcher.widgets.weather.WeatherWidget
import de.mm20.launcher2.widgets.Widget import de.mm20.launcher2.widgets.Widget
import de.mm20.launcher2.widgets.WidgetType import de.mm20.launcher2.widgets.WidgetType