This commit is contained in:
Max Kellermann 2024-01-18 09:59:48 +01:00
commit 7a40ac52a8
9 changed files with 354 additions and 216 deletions

View File

@ -61,6 +61,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("com.github.alorma:compose-settings-ui-m3:1.0.3")
implementation("com.github.alorma:compose-settings-storage-preferences:1.0.3")

View File

@ -28,7 +28,7 @@
android:theme="@style/Theme.MPD"
android:name=".MPDApplication">
<activity
android:name=".ui.SettingsActivity"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@ -27,7 +27,6 @@ import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaSession;
import org.musicpd.data.LoggingRepository;
import org.musicpd.ui.SettingsActivity;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
@ -208,7 +207,7 @@ public class Main extends Service implements Runnable {
}
}, filter);
final Intent mainIntent = new Intent(this, SettingsActivity.class);
final Intent mainIntent = new Intent(this, MainActivity.class);
mainIntent.setAction("android.intent.action.MAIN");
mainIntent.addCategory("android.intent.category.LAUNCHER");
final PendingIntent contentIntent = PendingIntent.getActivity(this, 0,

View File

@ -0,0 +1,57 @@
package org.musicpd
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme
import androidx.core.view.WindowCompat
import dagger.hilt.android.AndroidEntryPoint
import org.musicpd.ui.MPDApp
import org.musicpd.ui.SettingsViewModel
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
MaterialTheme {
MPDApp(settingsViewModel = settingsViewModel)
}
}
}
private fun connectClient() {
val client = Main.Client(this, object : Main.Client.Callback {
override fun onStopped() {
settingsViewModel.updateStatus("", false)
}
override fun onStarted() {
settingsViewModel.updateStatus("MPD Service Started", true)
}
override fun onError(error: String) {
settingsViewModel.removeClient()
settingsViewModel.updateStatus(error, false)
connectClient()
}
})
settingsViewModel.setClient(client)
}
override fun onStart() {
//mFirstRun = false
connectClient()
super.onStart()
}
override fun onStop() {
settingsViewModel.removeClient()
super.onStop()
}
}

View File

@ -0,0 +1,36 @@
package org.musicpd.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun LogView(messages: State<List<String>>) {
val state = rememberLazyListState()
Column(Modifier.fillMaxSize()) {
LazyColumn(
Modifier.padding(4.dp),
state
) {
items(messages.value) { message ->
Text(text = message, fontFamily = FontFamily.Monospace)
}
CoroutineScope(Dispatchers.Main).launch {
state.scrollToItem(messages.value.count(), 0)
}
}
}
}

View File

@ -0,0 +1,112 @@
package org.musicpd.ui
import MPDSettings
import android.graphics.drawable.Icon
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
enum class Screen {
HOME,
LOGS,
SETTINGS,
}
sealed class NavigationItem(val route: String, val label: String, val icon: ImageVector) {
data object Home : NavigationItem(
Screen.HOME.name,
"Home",
Icons.Default.Home
)
data object Logs : NavigationItem(
Screen.LOGS.name,
"Logs",
Icons.Default.List)
data object Settings : NavigationItem(
Screen.SETTINGS.name,
"Settings",
Icons.Default.Settings)
}
@Composable
fun MPDApp(
navController: NavHostController = rememberNavController(),
settingsViewModel: SettingsViewModel = viewModel()
) {
Scaffold(
topBar = {
},
bottomBar = {
BottomNavigationBar(navController)
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = NavigationItem.Home.route,
modifier = Modifier.padding(innerPadding)
) {
composable(NavigationItem.Home.route) {
StatusScreen(settingsViewModel)
}
composable(NavigationItem.Logs.route) {
LogView(settingsViewModel.getLogs().collectAsStateWithLifecycle())
}
composable(NavigationItem.Settings.route) {
MPDSettings(settingsViewModel)
}
}
}
}
@Composable
fun BottomNavigationBar(navController: NavController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val items = listOf(
NavigationItem.Home,
NavigationItem.Logs,
NavigationItem.Settings,
)
NavigationBar {
items.forEach { item ->
NavigationBarItem(
icon = {
Icon(
imageVector = item.icon,
contentDescription = null
)
},
label = { Text (item.label) },
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId)
launchSingleTop = true
}
},
selected = currentRoute == item.route,
)
}
}
}

View File

@ -1,210 +0,0 @@
package org.musicpd.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.musicpd.Main
import org.musicpd.R
@AndroidEntryPoint
class SettingsActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
SettingsContainer(settingsViewModel)
}
}
}
private fun connectClient() {
val client = Main.Client(this, object : Main.Client.Callback {
override fun onStopped() {
settingsViewModel.updateStatus("", false)
}
override fun onStarted() {
settingsViewModel.updateStatus("MPD Service Started", true)
}
override fun onError(error: String) {
settingsViewModel.removeClient()
settingsViewModel.updateStatus(error, false)
connectClient()
}
})
settingsViewModel.setClient(client)
}
override fun onStart() {
//mFirstRun = false
connectClient()
super.onStart()
}
override fun onStop() {
settingsViewModel.removeClient()
super.onStop()
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SettingsContainer(settingsViewModel: SettingsViewModel = viewModel()) {
val context = LocalContext.current
val storagePermissionState = rememberPermissionState(
android.Manifest.permission.READ_EXTERNAL_STORAGE
)
if (storagePermissionState.status.shouldShowRationale) {
Column(Modifier
.padding(4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(id = R.string.external_files_permission_request))
Button(onClick = { }) {
Text("Request permission")
}
}
} else {
Column {
NetworkAddress()
ServerStatus(settingsViewModel)
if (!storagePermissionState.status.isGranted) {
OutlinedButton(onClick = { storagePermissionState.launchPermissionRequest() }, Modifier
.padding(4.dp)
.fillMaxWidth()) {
Text("Request external storage permission", color = MaterialTheme.colorScheme.secondary)
}
}
SettingsOptions(
onBootChanged = { newValue ->
if (newValue) {
settingsViewModel.startMPD(context)
}
},
onWakeLockChanged = { newValue ->
settingsViewModel.setWakelockEnabled(newValue)
},
onHeadphonesChanged = { newValue ->
settingsViewModel.setPauseOnHeadphonesDisconnect(newValue)
}
)
LogView(settingsViewModel.getLogs().collectAsStateWithLifecycle())
}
}
}
@Composable
fun ServerStatus(settingsViewModel: SettingsViewModel) {
val context = LocalContext.current
val statusUiState by settingsViewModel.statusUIState.collectAsState()
Column {
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = "",
tint = if (statusUiState.running) Color(0xFFB8F397) else Color(0xFFFFDAD6)
)
Text(text = if (statusUiState.running) "Running" else "Stopped")
}
Button(onClick = {
if (statusUiState.running)
settingsViewModel.stopMPD()
else
settingsViewModel.startMPD(context)
}) {
Text(text = if (statusUiState.running) "Stop MPD" else "Start MPD")
}
}
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text(text = statusUiState.statusMessage)
}
}
}
@Composable
fun LogView(messages: State<List<String>>) {
val state = rememberLazyListState()
LazyColumn(
Modifier.padding(4.dp),
state
) {
items(messages.value) { message ->
Text(text = message, fontFamily = FontFamily.Monospace)
}
CoroutineScope(Dispatchers.Main).launch {
state.scrollToItem(messages.value.count(), 0)
}
}
}
@Preview(showBackground = true)
@Composable
fun SettingsPreview() {
MaterialTheme {
SettingsContainer()
}
}

View File

@ -1,5 +1,5 @@
package org.musicpd.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Headphones
@ -7,11 +7,35 @@ import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.alorma.compose.settings.storage.preferences.rememberPreferenceBooleanSettingState
import com.alorma.compose.settings.ui.SettingsSwitch
import org.musicpd.Preferences
import org.musicpd.R
import org.musicpd.ui.SettingsViewModel
@Composable
fun MPDSettings(settingsViewModel: SettingsViewModel) {
val context = LocalContext.current
Column(Modifier.fillMaxSize()) {
SettingsOptions(
onBootChanged = { newValue ->
if (newValue) {
settingsViewModel.startMPD(context)
}
},
onWakeLockChanged = { newValue ->
settingsViewModel.setWakelockEnabled(newValue)
},
onHeadphonesChanged = { newValue ->
settingsViewModel.setPauseOnHeadphonesDisconnect(newValue)
}
)
}
}
@Composable
fun SettingsOptions(
@ -49,4 +73,4 @@ fun SettingsOptions(
state = headphoneState
)
}
}

View File

@ -0,0 +1,119 @@
package org.musicpd.ui
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import org.musicpd.R
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun StatusScreen(settingsViewModel: SettingsViewModel) {
val storagePermissionState = rememberPermissionState(
Manifest.permission.READ_EXTERNAL_STORAGE
)
if (storagePermissionState.status.shouldShowRationale) {
Column(
Modifier
.padding(4.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(id = R.string.external_files_permission_request))
Button(onClick = { }) {
Text("Request permission")
}
}
} else {
Column(
Modifier
.padding(4.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
NetworkAddress()
ServerStatus(settingsViewModel)
if (!storagePermissionState.status.isGranted) {
OutlinedButton(
onClick = { storagePermissionState.launchPermissionRequest() }, Modifier
.padding(4.dp)
.fillMaxWidth()
) {
Text(
"Request external storage permission",
color = MaterialTheme.colorScheme.secondary
)
}
}
}
}
}
@Composable
fun ServerStatus(settingsViewModel: SettingsViewModel) {
val context = LocalContext.current
val statusUiState by settingsViewModel.statusUIState.collectAsState()
Column {
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row {
Icon(
imageVector = Icons.Default.Circle,
contentDescription = "",
tint = if (statusUiState.running) Color(0xFFB8F397) else Color(0xFFFFDAD6)
)
Text(text = if (statusUiState.running) "Running" else "Stopped")
}
Button(onClick = {
if (statusUiState.running)
settingsViewModel.stopMPD()
else
settingsViewModel.startMPD(context)
}) {
Text(text = if (statusUiState.running) "Stop MPD" else "Start MPD")
}
}
Row(
Modifier
.padding(4.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text(text = statusUiState.statusMessage)
}
}
}