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.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
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 de.mm20.launcher2.ui.BuildConfig
import de.mm20.launcher2.ui.R
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.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.component.preferences.SwitchPreference
@Composable
fun MediaIntegrationSettingsScreen() {
val context = LocalContext.current
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(
stringResource(R.string.preference_screen_musicwidget),
helpUrl = "https://kvaesitso.mm20.de/docs/user-guide/widgets/clock"
stringResource(R.string.preference_media_integration),
) {
if (loading) {
item {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
item {
PreferenceCategory {
AnimatedVisibility(hasPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_music_widget),
onClick = {
viewModel.requestNotificationPermission(context as AppCompatActivity)
AnimatedVisibility(hasPermission == false) {
MissingPermissionBanner(
text = stringResource(R.string.missing_permission_music_widget),
onClick = {
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) {

View File

@ -1,46 +1,117 @@
package de.mm20.launcher2.ui.settings.media
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
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.permissions.PermissionGroup
import de.mm20.launcher2.permissions.PermissionsManager
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.shareIn
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.math.roundToInt
class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent {
private val permissionsManager: PermissionsManager 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()
val hasPermission =
permissionsManager.hasPermission(PermissionGroup.Notifications).asLiveData()
permissionsManager.hasPermission(PermissionGroup.Notifications)
val loading = mutableStateOf(false)
fun requestNotificationPermission(activity: AppCompatActivity) {
permissionsManager.requestPermission(activity, PermissionGroup.Notifications)
}
val filterSources = dataStore.data.map { it.musicWidget.filterSources }.asLiveData()
fun setFilterSources(filterSources: Boolean) {
val appList = mutableStateOf(emptyList<AppListItem>())
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 {
dataStore.updateData {
it.toBuilder()
.setMusicWidget(
it.musicWidget.toBuilder()
.setFilterSources(filterSources)
.clearAllowList()
.addAllAllowList(allowList)
.clearDenyList()
.addAllDenyList(denyList)
)
.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_weatherwidget">Weather</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_summary">Configure clock style and components</string>
<string name="preference_clockwidget_layout">Layout</string>

View File

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

View File

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

View File

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