Let users pick media player apps

This commit is contained in:
MM20 2023-04-16 18:36:59 +02:00
parent c686fa25c1
commit c59fa5b667
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
7 changed files with 194 additions and 55 deletions

View File

@ -29,4 +29,31 @@ fun CheckboxPreference(
) )
} }
) )
}
@Composable
fun CheckboxPreference(
title: String,
icon: @Composable () -> Unit,
iconPadding: Boolean = true,
summary: String? = null,
value: Boolean,
onValueChanged: (Boolean) -> Unit,
enabled: Boolean = true
) {
Preference(
title = title,
icon = icon,
iconPadding = iconPadding,
summary = summary,
enabled = enabled,
onClick = {
onValueChanged(!value)
},
controls = {
Checkbox(
enabled = enabled, checked = value, onCheckedChange = onValueChanged,
)
}
)
} }

View File

@ -2,52 +2,87 @@ package de.mm20.launcher2.ui.settings.media
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.ui.BuildConfig import de.mm20.launcher2.ui.BuildConfig
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.MissingPermissionBanner import de.mm20.launcher2.ui.component.MissingPermissionBanner
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.preferences.CheckboxPreference
import de.mm20.launcher2.ui.component.preferences.Preference import de.mm20.launcher2.ui.component.preferences.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable @Composable
fun MediaIntegrationSettingsScreen() { fun MediaIntegrationSettingsScreen() {
val context = LocalContext.current val context = LocalContext.current
val viewModel: MediaIntegrationSettingsScreenVM = viewModel() val viewModel: MediaIntegrationSettingsScreenVM = viewModel()
val hasPermission by viewModel.hasPermission.observeAsState() val hasPermission by viewModel.hasPermission.collectAsStateWithLifecycle(null)
val loading by viewModel.loading
val density = LocalDensity.current
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(null) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.onResume(density = density.density)
}
}
PreferenceScreen( PreferenceScreen(
stringResource(R.string.preference_screen_musicwidget), stringResource(R.string.preference_media_integration),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/widgets/clock"
) { ) {
if (loading) {
item {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
item { item {
PreferenceCategory { AnimatedVisibility(hasPermission == false) {
AnimatedVisibility(hasPermission == false) { MissingPermissionBanner(
MissingPermissionBanner( text = stringResource(R.string.missing_permission_music_widget),
text = stringResource(R.string.missing_permission_music_widget), onClick = {
onClick = { viewModel.requestNotificationPermission(context as AppCompatActivity)
viewModel.requestNotificationPermission(context as AppCompatActivity) },
modifier = Modifier.padding(16.dp)
)
}
PreferenceCategory(
stringResource(R.string.preference_category_media_apps)
) {
val apps by viewModel.appList
for (app in apps) {
val icon by app.icon.collectAsState(null)
CheckboxPreference(
icon = {
ShapedLauncherIcon(size = 32.dp, icon = { icon })
}, },
modifier = Modifier.padding(16.dp) title = app.label,
value = app.isChecked,
onValueChanged = {
viewModel.onAppChecked(app, it)
}
) )
} }
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) { if (BuildConfig.DEBUG) {

View File

@ -1,46 +1,117 @@
package de.mm20.launcher2.ui.settings.media package de.mm20.launcher2.ui.settings.media
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.ktx.normalize
import de.mm20.launcher2.music.MusicService import de.mm20.launcher2.music.MusicService
import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.permissions.PermissionsManager
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherDataStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.math.roundToInt
class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent { class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val permissionsManager: PermissionsManager by inject() private val permissionsManager: PermissionsManager by inject()
private val musicService: MusicService by inject() private val musicService: MusicService by inject()
private val appRepository: AppRepository by inject()
private val iconService: IconService by inject()
private val dataStore: LauncherDataStore by inject() private val dataStore: LauncherDataStore by inject()
val hasPermission = val hasPermission =
permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData() permissionsManager.hasPermission(PermissionGroup.Notifications)
val loading = mutableStateOf(false)
fun requestNotificationPermission(activity: AppCompatActivity) { fun requestNotificationPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Notifications) permissionsManager.requestPermission(activity, PermissionGroup.Notifications)
} }
val filterSources = dataStore.data.map { it.musicWidget.filterSources }.asLiveData() val appList = mutableStateOf(emptyList<AppListItem>())
fun setFilterSources(filterSources: Boolean) {
fun resetWidget() {
musicService.resetPlayer()
}
fun onResume(density: Float) {
loading.value = true
viewModelScope.launch(Dispatchers.Default) {
val musicApps = musicService.getInstalledPlayerPackages()
val allApps = appRepository.getAllInstalledApps().first().filter { it.isMainProfile }
.distinctBy { it.`package` }
val settings = dataStore.data.map { it.musicWidget }.first()
val allowList = settings.allowListList
val denyList = settings.denyListList
appList.value = allApps.map {
AppListItem(
label = it.label,
packageName = it.`package`,
isMusicApp = musicApps.contains(it.`package`),
isChecked = allowList.contains(it.`package`) || (!denyList.contains(it.`package`) && musicApps.contains(
it.`package`
)),
icon = iconService.getIcon(it, (32 * density).roundToInt())
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(10000))
)
}.sortedBy { it.label.normalize() }
loading.value = false
}
}
fun onAppChecked(app: AppListItem, checked: Boolean) {
val list = appList.value.toMutableList()
val index = list.indexOf(app)
list[index] = app.copy(isChecked = checked)
appList.value = list
saveState()
}
private fun saveState() {
val allowList = mutableListOf<String>()
val denyList = mutableListOf<String>()
val appList = appList.value
for (app in appList) {
if (app.isChecked && !app.isMusicApp) {
allowList.add(app.packageName)
} else if (!app.isChecked && app.isMusicApp) {
denyList.add(app.packageName)
}
}
viewModelScope.launch { viewModelScope.launch {
dataStore.updateData { dataStore.updateData {
it.toBuilder() it.toBuilder()
.setMusicWidget( .setMusicWidget(
it.musicWidget.toBuilder() it.musicWidget.toBuilder()
.setFilterSources(filterSources) .clearAllowList()
.addAllAllowList(allowList)
.clearDenyList()
.addAllDenyList(denyList)
) )
.build() .build()
} }
} }
} }
fun resetWidget() { }
musicService.resetPlayer()
}
} data class AppListItem(
val label: String,
val packageName: String,
val isMusicApp: Boolean,
val isChecked: Boolean,
val icon: Flow<LauncherIcon>,
)

View File

@ -550,6 +550,7 @@
<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_screen_musicwidget">Music</string>
<string name="preference_category_media_apps">Media apps</string>
<string name="preference_screen_clockwidget">Clock</string> <string name="preference_screen_clockwidget">Clock</string>
<string name="preference_screen_clockwidget_summary">Configure clock style and components</string> <string name="preference_screen_clockwidget_summary">Configure clock style and components</string>
<string name="preference_clockwidget_layout">Layout</string> <string name="preference_clockwidget_layout">Layout</string>

View File

@ -34,7 +34,6 @@ fun createFactorySettings(context: Context): Settings {
.setMusicWidget( .setMusicWidget(
Settings.MusicWidgetSettings Settings.MusicWidgetSettings
.newBuilder() .newBuilder()
.setFilterSources(true)
.build() .build()
) )
.setCalendarWidget( .setCalendarWidget(

View File

@ -97,7 +97,9 @@ message Settings {
WeatherSettings weather = 5; WeatherSettings weather = 5;
message MusicWidgetSettings { message MusicWidgetSettings {
bool filter_sources = 1; reserved 1;
repeated string allow_list = 2;
repeated string deny_list = 3;
} }
MusicWidgetSettings music_widget = 6; MusicWidgetSettings music_widget = 6;

View File

@ -4,11 +4,11 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.AudioManager import android.media.AudioManager
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.Rating
import android.media.session.MediaController import android.media.session.MediaController
import android.media.session.MediaSession import android.media.session.MediaSession
import android.media.session.PlaybackState.CustomAction import android.media.session.PlaybackState.CustomAction
@ -17,13 +17,11 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader import coil.imageLoader
import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.Scale import coil.size.Scale
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
@ -75,6 +73,8 @@ interface MusicService {
fun openPlayerChooser(context: Context) fun openPlayerChooser(context: Context)
suspend fun getInstalledPlayerPackages(): Set<String>
fun resetPlayer() fun resetPlayer()
} }
@ -107,13 +107,15 @@ internal class MusicServiceImpl(
private val currentMediaController: SharedFlow<MediaController?> = private val currentMediaController: SharedFlow<MediaController?> =
combine( combine(
notificationRepository.notifications, notificationRepository.notifications,
dataStore.data.map { it.musicWidget.filterSources } dataStore.data.map { it.musicWidget }
) { notifications, filter -> ) { notifications, settings ->
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val musicApps = if (filter) getMusicApps() else null val musicApps = getEnabledPlayerPackages(
settings.allowListList.toSet(),
settings.denyListList.toSet()
)
val sbn: StatusBarNotification? = notifications.filter { val sbn: StatusBarNotification? = notifications.filter {
it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null && it.notification.extras.getParcelable(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token != null && musicApps.contains(it.packageName)
(musicApps?.contains(it.packageName) != false)
}.maxByOrNull { it.postTime } }.maxByOrNull { it.postTime }
return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token) return@withContext (sbn?.notification?.extras?.get(NotificationCompat.EXTRA_MEDIA_SESSION) as? MediaSession.Token)
@ -567,25 +569,27 @@ internal class MusicServiceImpl(
} }
} }
private fun getMusicApps(): Set<String> { override suspend fun getInstalledPlayerPackages(): Set<String> {
// List of known music apps that don't have the correct intent filter val apps = mutableSetOf<String>()
val apps = mutableSetOf( withContext(Dispatchers.IO) {
"com.aspiro.tidal", // Tidal var intent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_APP_MUSIC) }
"com.bandcamp.android", // Bandcamp apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
"com.qobuz.music", // Qobuz .map { it.activityInfo.applicationInfo.packageName })
"tv.plex.labs.plexamp", // Plexamp intent = Intent("android.intent.action.MUSIC_PLAYER")
"de.ph1b.audiobook", // Voice apps.addAll(context.packageManager.queryIntentActivities(intent, 0)
"de.eindm.boum", // Boum .map { it.activityInfo.applicationInfo.packageName })
) }
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 })
return apps return apps
} }
private suspend fun getEnabledPlayerPackages(
allowList: Set<String>,
denyList: Set<String>
): Set<String> {
val installed = getInstalledPlayerPackages()
return installed.union(allowList).subtract(denyList).toSet()
}
override fun resetPlayer() { override fun resetPlayer() {
scope.launch { scope.launch {
preferences.edit { preferences.edit {