This commit is contained in:
Max Kellermann 2023-12-27 12:35:13 +01:00
commit 658a7f1ca7
16 changed files with 476 additions and 372 deletions

View File

@ -1,5 +1,6 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android")
} }
android { android {
@ -12,10 +13,18 @@ android {
targetSdk = 30 targetSdk = 30
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
vectorDrawables {
useSupportLibrary = true
}
} }
buildFeatures { buildFeatures {
aidl = true aidl = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
} }
buildTypes { buildTypes {
@ -31,8 +40,34 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_9 sourceCompatibility = JavaVersion.VERSION_1_9
targetCompatibility = JavaVersion.VERSION_1_9 targetCompatibility = JavaVersion.VERSION_1_9
} }
kotlinOptions {
jvmTarget = "1.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
} }
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.8.2")
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("com.github.alorma:compose-settings-ui-m3:1.0.3")
implementation("com.github.alorma:compose-settings-storage-preferences:1.0.3")
implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha")
// Android Studio Preview support
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.appcompat:appcompat:1.6.1")
} }

View File

@ -24,16 +24,19 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.MPD">
<activity <activity
android:name=".Settings" android:name=".ui.SettingsActivity"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>

View File

@ -25,6 +25,8 @@ import android.widget.RemoteViews;
import androidx.core.app.ServiceCompat; import androidx.core.app.ServiceCompat;
import org.musicpd.ui.SettingsActivity;
import java.lang.reflect.Constructor; import java.lang.reflect.Constructor;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@ -197,7 +199,7 @@ public class Main extends Service implements Runnable {
} }
}, filter); }, filter);
final Intent mainIntent = new Intent(this, Settings.class); final Intent mainIntent = new Intent(this, SettingsActivity.class);
mainIntent.setAction("android.intent.action.MAIN"); mainIntent.setAction("android.intent.action.MAIN");
mainIntent.addCategory("android.intent.category.LAUNCHER"); mainIntent.addCategory("android.intent.category.LAUNCHER");
final PendingIntent contentIntent = PendingIntent.getActivity(this, 0, final PendingIntent contentIntent = PendingIntent.getActivity(this, 0,

View File

@ -5,12 +5,15 @@ import android.net.ConnectivityManager;
import android.net.LinkAddress; import android.net.LinkAddress;
import android.net.LinkProperties; import android.net.LinkProperties;
import androidx.annotation.Nullable;
import java.net.Inet4Address; import java.net.Inet4Address;
import java.net.InetAddress; import java.net.InetAddress;
import java.util.List; import java.util.List;
public class NetworkUtil { public class NetworkUtil {
@Nullable
public static String getDeviceIPV4Address(Context context) { public static String getDeviceIPV4Address(Context context) {
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
LinkProperties linkProperties = connectivityManager.getLinkProperties(connectivityManager.getActiveNetwork()); LinkProperties linkProperties = connectivityManager.getLinkProperties(connectivityManager.getActiveNetwork());

View File

@ -0,0 +1,34 @@
package org.musicpd;
import static android.content.Context.MODE_PRIVATE;
import android.content.Context;
import android.content.SharedPreferences;
public class Preferences {
private static final String TAG = "Settings";
public static final String KEY_RUN_ON_BOOT ="run_on_boot";
public static final String KEY_WAKELOCK ="wakelock";
public static final String KEY_PAUSE_ON_HEADPHONES_DISCONNECT ="pause_on_headphones_disconnect";
public static SharedPreferences get(Context context) {
return context.getSharedPreferences(TAG, MODE_PRIVATE);
}
public static void putBoolean(Context context, String key, boolean value) {
final SharedPreferences prefs = get(context);
if (prefs == null)
return;
final SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(key, value);
editor.apply();
}
public static boolean getBoolean(Context context, String key, boolean defValue) {
final SharedPreferences prefs = get(context);
return prefs != null ? prefs.getBoolean(key, defValue) : defValue;
}
}

View File

@ -20,13 +20,13 @@ public class Receiver extends BroadcastReceiver {
@Override @Override
public void onReceive(Context context, Intent intent) { public void onReceive(Context context, Intent intent) {
Log.d("Receiver", "onReceive: " + intent); Log.d("Receiver", "onReceive: " + intent);
if (BOOT_ACTIONS.contains(intent.getAction())) { if (intent.getAction() == "android.intent.action.BOOT_COMPLETED") {
if (Settings.Preferences.getBoolean(context, if (Settings.Preferences.getBoolean(context,
Settings.Preferences.KEY_RUN_ON_BOOT, Settings.Preferences.KEY_RUN_ON_BOOT,
false)) { false)) {
final boolean wakelock = final boolean wakelock =
Settings.Preferences.getBoolean(context, Preferences.getBoolean(context,
Settings.Preferences.KEY_WAKELOCK, false); Preferences.KEY_WAKELOCK, false);
Main.start(context, wakelock); Main.start(context, wakelock);
} }
} }

View File

@ -1,296 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright The Music Player Daemon Project
package org.musicpd;
import java.util.LinkedList;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.widget.ArrayAdapter;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.CompoundButton.OnCheckedChangeListener;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.ToggleButton;
public class Settings extends Activity {
private static final String TAG = "Settings";
private Main.Client mClient;
private TextView mTextStatus;
private ToggleButton mRunButton;
private boolean mFirstRun;
private LinkedList<String> mLogListArray = new LinkedList<String>();
private ListView mLogListView;
private ArrayAdapter<String> mLogListAdapter;
private static final int MAX_LOGS = 500;
private static final int MSG_ERROR = 0;
private static final int MSG_STOPPED = 1;
private static final int MSG_STARTED = 2;
private static final int MSG_LOG = 3;
public static class Preferences {
public static final String KEY_RUN_ON_BOOT ="run_on_boot";
public static final String KEY_WAKELOCK ="wakelock";
public static final String KEY_PAUSE_ON_HEADPHONES_DISCONNECT ="pause_on_headphones_disconnect";
public static SharedPreferences get(Context context) {
return context.getSharedPreferences(TAG, MODE_PRIVATE);
}
public static void putBoolean(Context context, String key, boolean value) {
final SharedPreferences prefs = get(context);
if (prefs == null)
return;
final Editor editor = prefs.edit();
editor.putBoolean(key, value);
editor.apply();
}
public static boolean getBoolean(Context context, String key, boolean defValue) {
final SharedPreferences prefs = get(context);
return prefs != null ? prefs.getBoolean(key, defValue) : defValue;
}
}
private Handler mHandler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_ERROR:
Log.d(TAG, "onError");
mClient.release();
connectClient();
mRunButton.setEnabled(false);
mRunButton.setChecked(false);
mTextStatus.setText((String)msg.obj);
mFirstRun = true;
break;
case MSG_STOPPED:
Log.d(TAG, "onStopped");
mRunButton.setEnabled(true);
if (!mFirstRun && Preferences.getBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, false))
mRunButton.setChecked(true);
else
mRunButton.setChecked(false);
mFirstRun = true;
mTextStatus.setText("");
break;
case MSG_STARTED:
Log.d(TAG, "onStarted");
mRunButton.setChecked(true);
mFirstRun = true;
mTextStatus.setText("MPD service started");
break;
case MSG_LOG:
if (mLogListArray.size() > MAX_LOGS)
mLogListArray.remove(0);
String priority;
switch (msg.arg1) {
case Log.DEBUG:
priority = "D";
break;
case Log.ERROR:
priority = "E";
break;
case Log.INFO:
priority = "I";
break;
case Log.VERBOSE:
priority = "V";
break;
case Log.WARN:
priority = "W";
break;
default:
priority = "";
}
mLogListArray.add(priority + "/ " + (String)msg.obj);
mLogListAdapter.notifyDataSetChanged();
break;
}
return true;
}
});
private final OnCheckedChangeListener mOnRunChangeListener = new OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
if (mClient != null) {
if (isChecked) {
mClient.start();
if (Preferences.getBoolean(Settings.this,
Preferences.KEY_WAKELOCK, false))
mClient.setWakelockEnabled(true);
if (Preferences.getBoolean(Settings.this,
Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false))
mClient.setPauseOnHeadphonesDisconnect(true);
} else {
mClient.stop();
}
}
}
};
private final OnCheckedChangeListener mOnRunOnBootChangeListener = new OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Preferences.putBoolean(Settings.this, Preferences.KEY_RUN_ON_BOOT, isChecked);
if (isChecked && mClient != null && !mRunButton.isChecked())
mRunButton.setChecked(true);
}
};
private final OnCheckedChangeListener mOnWakelockChangeListener = new OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Preferences.putBoolean(Settings.this, Preferences.KEY_WAKELOCK, isChecked);
if (mClient != null && mClient.isRunning())
mClient.setWakelockEnabled(isChecked);
}
};
private final OnCheckedChangeListener mOnPauseOnHeadphonesDisconnectChangeListener = new OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
Preferences.putBoolean(Settings.this, Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, isChecked);
if (mClient != null && mClient.isRunning())
mClient.setPauseOnHeadphonesDisconnect(isChecked);
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
/* TODO: this sure is the wrong place to request
permissions - it will cause MPD to quit
immediately; we should request permissions when we
need them, but implementing that is complicated, so
for now, we do it here to give users a quick
solution for the problem */
requestAllPermissions();
setContentView(R.layout.settings);
mRunButton = (ToggleButton) findViewById(R.id.run);
mRunButton.setOnCheckedChangeListener(mOnRunChangeListener);
mTextStatus = (TextView) findViewById(R.id.status);
mLogListAdapter = new ArrayAdapter<String>(this, R.layout.log_item, mLogListArray);
mLogListView = (ListView) findViewById(R.id.log_list);
mLogListView.setAdapter(mLogListAdapter);
mLogListView.setTranscriptMode(ListView.TRANSCRIPT_MODE_NORMAL);
CheckBox checkbox = (CheckBox) findViewById(R.id.run_on_boot);
checkbox.setOnCheckedChangeListener(mOnRunOnBootChangeListener);
if (Preferences.getBoolean(this, Preferences.KEY_RUN_ON_BOOT, false))
checkbox.setChecked(true);
checkbox = (CheckBox) findViewById(R.id.wakelock);
checkbox.setOnCheckedChangeListener(mOnWakelockChangeListener);
if (Preferences.getBoolean(this, Preferences.KEY_WAKELOCK, false))
checkbox.setChecked(true);
checkbox = (CheckBox) findViewById(R.id.pause_on_headphones_disconnect);
checkbox.setOnCheckedChangeListener(mOnPauseOnHeadphonesDisconnectChangeListener);
if (Preferences.getBoolean(this, Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false))
checkbox.setChecked(true);
TextView networkAddressTextView = (TextView) findViewById(R.id.networkAddress);
String deviceIPV4Address = NetworkUtil.getDeviceIPV4Address(this);
networkAddressTextView.setText(deviceIPV4Address);
super.onCreate(savedInstanceState);
}
private void checkRequestPermission(String permission) {
if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
return;
try {
this.requestPermissions(new String[]{permission}, 0);
} catch (Exception e) {
Log.e(TAG, "requestPermissions(" + permission + ") failed",
e);
}
}
private void requestAllPermissions() {
if (android.os.Build.VERSION.SDK_INT < 23)
/* we don't need to request permissions on
this old Android version */
return;
/* starting with Android 6.0, we need to explicitly
request all permissions before using them;
mentioning them in the manifest is not enough */
checkRequestPermission(Manifest.permission.READ_EXTERNAL_STORAGE);
}
private void connectClient() {
mClient = new Main.Client(this, new Main.Client.Callback() {
private void removeMessages() {
/* don't remove log messages */
mHandler.removeMessages(MSG_STOPPED);
mHandler.removeMessages(MSG_STARTED);
mHandler.removeMessages(MSG_ERROR);
}
@Override
public void onStopped() {
removeMessages();
mHandler.sendEmptyMessage(MSG_STOPPED);
}
@Override
public void onStarted() {
removeMessages();
mHandler.sendEmptyMessage(MSG_STARTED);
}
@Override
public void onError(String error) {
removeMessages();
mHandler.sendMessage(Message.obtain(mHandler, MSG_ERROR, error));
}
@Override
public void onLog(int priority, String msg) {
mHandler.sendMessage(Message.obtain(mHandler, MSG_LOG, priority, 0, msg));
}
});
}
@Override
protected void onStart() {
mFirstRun = false;
connectClient();
super.onStart();
}
@Override
protected void onStop() {
mClient.release();
mClient = null;
super.onStop();
}
}

View File

@ -0,0 +1,39 @@
package org.musicpd.ui
import android.app.Application
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import org.musicpd.NetworkUtil
@Composable
fun NetworkAddress() {
val address = NetworkUtil.getDeviceIPV4Address(LocalContext.current)
val padding = 4.dp
Row(
Modifier
.padding(padding)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = "Wifi")
Spacer(Modifier.size(padding))
Text(text = address ?: "")
}
}

View File

@ -0,0 +1,52 @@
package org.musicpd.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Headphones
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.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
@Composable
fun SettingsOptions(
onBootChanged: (Boolean) -> Unit,
onWakeLockChanged: (Boolean) -> Unit,
onHeadphonesChanged: (Boolean) -> Unit
) {
val bootState = rememberPreferenceBooleanSettingState(
key = Preferences.KEY_RUN_ON_BOOT,
defaultValue = false
)
val wakelockState =
rememberPreferenceBooleanSettingState(key = Preferences.KEY_WAKELOCK, defaultValue = false)
val headphoneState = rememberPreferenceBooleanSettingState(
key = Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT,
defaultValue = false
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.PowerSettingsNew, contentDescription = "Power") },
title = { Text(text = stringResource(R.string.checkbox_run_on_boot)) },
onCheckedChange = onBootChanged,
state = bootState
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.BatteryAlert, contentDescription = "Battery") },
title = { Text(text = stringResource(R.string.checkbox_wakelock)) },
onCheckedChange = onWakeLockChanged,
state = wakelockState
)
SettingsSwitch(
icon = { Icon(imageVector = Icons.Default.Headphones, contentDescription = "Headphones") },
title = { Text(text = stringResource(R.string.checkbox_pause_on_headphones_disconnect)) },
onCheckedChange = onHeadphonesChanged,
state = headphoneState
)
}

View File

@ -0,0 +1,212 @@
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.musicpd.Main
import org.musicpd.R
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()
}
override fun onLog(priority: Int, msg: String) {
settingsViewModel.addLogItem(priority, msg)
}
})
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.logItemFLow.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

@ -0,0 +1,84 @@
package org.musicpd.ui
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.musicpd.Main
import org.musicpd.Preferences
private const val MAX_LOGS = 500
class SettingsViewModel : ViewModel() {
private var mClient: Main.Client? = null
private val _logItemFLow = MutableStateFlow(listOf<String>())
val logItemFLow: StateFlow<List<String>> = _logItemFLow
data class StatusUiState(
val statusMessage: String = "",
val running: Boolean = false
)
private val _statusUIState = MutableStateFlow(StatusUiState())
val statusUIState: StateFlow<StatusUiState> = _statusUIState.asStateFlow()
fun addLogItem(priority: Int, message: String) {
if (_logItemFLow.value.size > MAX_LOGS) {
_logItemFLow.value = _logItemFLow.value.drop(1)
}
val priorityString: String = when (priority) {
Log.DEBUG -> "D"
Log.ERROR -> "E"
Log.INFO -> "I"
Log.VERBOSE -> "V"
Log.WARN -> "W"
else -> ""
}
_logItemFLow.value = _logItemFLow.value + ("$priorityString/$message")
}
fun updateStatus(message: String, running: Boolean) {
_statusUIState.value = StatusUiState(message, running)
}
fun setClient(client: Main.Client) {
mClient = client
}
fun removeClient() {
mClient?.release()
mClient = null
}
fun startMPD(context: Context) {
mClient?.start()
if (Preferences.getBoolean(
context,
Preferences.KEY_WAKELOCK, false
)
) mClient?.setWakelockEnabled(true)
if (Preferences.getBoolean(
context,
Preferences.KEY_PAUSE_ON_HEADPHONES_DISCONNECT, false
)
) mClient?.setPauseOnHeadphonesDisconnect(true)
}
fun stopMPD() {
mClient?.stop()
}
fun setWakelockEnabled(enabled: Boolean) {
mClient?.setWakelockEnabled(enabled)
}
fun setPauseOnHeadphonesDisconnect(enabled: Boolean) {
mClient?.setPauseOnHeadphonesDisconnect(enabled)
}
}

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:typeface="monospace" />

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="center"
android:src="@drawable/baseline_wifi_24"
android:layout_margin="4dp"/>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/networkAddress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginRight="24sp"
android:gravity="center"
android:padding="4dp"
android:textSize="24sp" />
</LinearLayout>
<ToggleButton
android:id="@+id/run"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textOff="@string/toggle_button_run_off"
android:textOn="@string/toggle_button_run_on" />
<CheckBox
android:id="@+id/run_on_boot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/checkbox_run_on_boot" />
<CheckBox
android:id="@+id/wakelock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/checkbox_wakelock" />
<CheckBox
android:id="@+id/pause_on_headphones_disconnect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/checkbox_pause_on_headphones_disconnect" />
<TextView
android:id="@+id/status"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<ListView
android:id="@+id/log_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="10dip" />
</LinearLayout>

View File

@ -9,4 +9,5 @@
<string name="checkbox_run_on_boot">Run MPD automatically on boot</string> <string name="checkbox_run_on_boot">Run MPD automatically on boot</string>
<string name="checkbox_wakelock">Prevent suspend when MPD is running (Wakelock)</string> <string name="checkbox_wakelock">Prevent suspend when MPD is running (Wakelock)</string>
<string name="checkbox_pause_on_headphones_disconnect">Pause MPD when headphones disconnect</string> <string name="checkbox_pause_on_headphones_disconnect">Pause MPD when headphones disconnect</string>
<string name="external_files_permission_request">MPD requires access to external files to play local music. Please grant the permission.</string>
</resources> </resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MPD" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@ -1,4 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id("com.android.application") version "8.1.2" apply false id("com.android.application") version "8.1.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
} }