mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-19 07:15:25 +02:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
d6b9f56a60
@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- Required for Bluetooth microphones -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
@ -39,7 +43,9 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
|
<service
|
||||||
|
android:name=".services.AudioRecorderService"
|
||||||
|
android:foregroundServiceType="microphone" />
|
||||||
|
|
||||||
<!-- Change locale for Android <= 12 -->
|
<!-- Change locale for Android <= 12 -->
|
||||||
<service
|
<service
|
||||||
|
@ -87,6 +87,7 @@ data class AudioRecorderSettings(
|
|||||||
val samplingRate: Int? = null,
|
val samplingRate: Int? = null,
|
||||||
val outputFormat: Int? = null,
|
val outputFormat: Int? = null,
|
||||||
val encoder: Int? = null,
|
val encoder: Int? = null,
|
||||||
|
val showAllMicrophones: Boolean = false,
|
||||||
) {
|
) {
|
||||||
fun getOutputFormat(): Int {
|
fun getOutputFormat(): Int {
|
||||||
if (outputFormat != null) {
|
if (outputFormat != null) {
|
||||||
@ -167,7 +168,6 @@ data class AudioRecorderSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||||
println("bitRate: $bitRate")
|
|
||||||
if (bitRate !in 1000..320000) {
|
if (bitRate !in 1000..320000) {
|
||||||
throw Exception("Bit rate must be between 1000 and 320000")
|
throw Exception("Bit rate must be between 1000 and 320000")
|
||||||
}
|
}
|
||||||
@ -215,6 +215,10 @@ data class AudioRecorderSettings(
|
|||||||
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||||
|
return copy(showAllMicrophones = showAllMicrophones)
|
||||||
|
}
|
||||||
|
|
||||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||||
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
||||||
return true
|
return true
|
||||||
|
@ -1,26 +1,72 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceCallback
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.MediaRecorder.OnErrorListener
|
import android.media.MediaRecorder.OnErrorListener
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.content.ContextCompat.getSystemService
|
||||||
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
import java.lang.IllegalStateException
|
import java.lang.IllegalStateException
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
class AudioRecorderService : IntervalRecorderService() {
|
class AudioRecorderService : IntervalRecorderService() {
|
||||||
var amplitudesAmount = 1000
|
var amplitudesAmount = 1000
|
||||||
|
var selectedMicrophone: MicrophoneInfo? = null
|
||||||
|
|
||||||
var recorder: MediaRecorder? = null
|
var recorder: MediaRecorder? = null
|
||||||
private set
|
private set
|
||||||
var onError: () -> Unit = {}
|
var onError: () -> Unit = {}
|
||||||
|
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||||
|
var onMicrophoneDisconnected: () -> Unit = {}
|
||||||
|
var onMicrophoneReconnected: () -> Unit = {}
|
||||||
|
|
||||||
val filePath: String
|
val filePath: String
|
||||||
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
|
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
|
||||||
|
|
||||||
|
/// Tell Android to use the correct bluetooth microphone, if any selected
|
||||||
|
private fun startAudioDevice() {
|
||||||
|
if (selectedMicrophone == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
|
||||||
|
} else {
|
||||||
|
audioManger.startBluetoothSco()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAudioDevice() {
|
||||||
|
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManger.clearCommunicationDevice()
|
||||||
|
} else {
|
||||||
|
audioManger.stopBluetoothSco()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createRecorder(): MediaRecorder {
|
private fun createRecorder(): MediaRecorder {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
MediaRecorder(this)
|
MediaRecorder(this)
|
||||||
} else {
|
} else {
|
||||||
MediaRecorder()
|
MediaRecorder()
|
||||||
}.apply {
|
}.apply {
|
||||||
|
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
||||||
|
// and Redmi Buds 3 Pro:
|
||||||
|
// - MIC: Uses the bottom microphone of the phone (17)
|
||||||
|
// - CAMCORDER: Uses the top microphone of the phone (2)
|
||||||
|
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
||||||
|
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
setOutputFile(filePath)
|
setOutputFile(filePath)
|
||||||
setOutputFormat(settings!!.outputFormat)
|
setOutputFormat(settings!!.outputFormat)
|
||||||
@ -39,6 +85,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
it.stop()
|
it.stop()
|
||||||
it.release()
|
it.release()
|
||||||
}
|
}
|
||||||
|
clearAudioDevice()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +97,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
|
startAudioDevice()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recorder = newRecorder
|
recorder = newRecorder
|
||||||
@ -59,6 +107,12 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
super.start()
|
||||||
|
|
||||||
|
registerMicrophoneListener()
|
||||||
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
super.pause()
|
super.pause()
|
||||||
|
|
||||||
@ -69,6 +123,8 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
super.stop()
|
super.stop()
|
||||||
|
|
||||||
resetRecorder()
|
resetRecorder()
|
||||||
|
selectedMicrophone = null
|
||||||
|
unregisterMicrophoneListener()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||||
@ -82,4 +138,66 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
||||||
|
selectedMicrophone = microphone
|
||||||
|
onSelectedMicrophoneChange(microphone)
|
||||||
|
|
||||||
|
if (state == RecorderState.RECORDING) {
|
||||||
|
startNewCycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||||
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
|
||||||
|
super.onAudioDevicesAdded(addedDevices)
|
||||||
|
|
||||||
|
if (selectedMicrophone == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't compare the ID, as it seems to be changing on each reconnect
|
||||||
|
val newDevice = addedDevices?.find {
|
||||||
|
it.productName == selectedMicrophone!!.deviceInfo.productName &&
|
||||||
|
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
|
||||||
|
it.type == selectedMicrophone!!.deviceInfo.type && (
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
it.address == selectedMicrophone!!.deviceInfo.address
|
||||||
|
} else true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (newDevice != null) {
|
||||||
|
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
|
||||||
|
|
||||||
|
onMicrophoneReconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
|
||||||
|
super.onAudioDevicesRemoved(removedDevices)
|
||||||
|
|
||||||
|
if (selectedMicrophone == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
||||||
|
onMicrophoneDisconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerMicrophoneListener() {
|
||||||
|
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||||
|
|
||||||
|
audioManager.registerAudioDeviceCallback(
|
||||||
|
audioDeviceCallback,
|
||||||
|
Handler(Looper.getMainLooper())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterMicrophoneListener() {
|
||||||
|
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||||
|
|
||||||
|
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||||
|
}
|
||||||
}
|
}
|
@ -2,26 +2,19 @@ package app.myzel394.alibi.services
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import app.myzel394.alibi.MainActivity
|
|
||||||
import app.myzel394.alibi.NotificationHelper
|
import app.myzel394.alibi.NotificationHelper
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@ -115,10 +108,7 @@ abstract class RecorderService : Service() {
|
|||||||
isPaused = true
|
isPaused = true
|
||||||
}
|
}
|
||||||
|
|
||||||
RecorderState.IDLE -> {
|
else -> {}
|
||||||
stop()
|
|
||||||
onDestroy()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (newState) {
|
when (newState) {
|
||||||
@ -170,6 +160,7 @@ abstract class RecorderService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
||||||
|
stop()
|
||||||
changeState(RecorderState.IDLE)
|
changeState(RecorderState.IDLE)
|
||||||
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
@ -0,0 +1,59 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
ConfirmDeletionDialog(
|
||||||
|
onDismiss = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
},
|
||||||
|
onConfirm = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
onDelete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = true
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneDisconnectedDialog(
|
||||||
|
microphoneName: String,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.ui_audioRecorder_error_microphoneDisconnected_title,
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
||||||
|
microphoneName,
|
||||||
|
microphoneName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MicOff,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val label = stringResource(R.string.dialog_close_neutral_label)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
onClick = onClose,
|
||||||
|
) {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MicOff
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneReconnectedDialog(
|
||||||
|
microphoneName: String,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.ui_audioRecorder_error_microphoneReconnected_title,
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.ui_audioRecorder_error_microphoneReconnected_message,
|
||||||
|
microphoneName,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Star,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
val label = stringResource(R.string.dialog_close_neutral_label)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
},
|
||||||
|
onClick = onClose,
|
||||||
|
) {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneSelectionButton(
|
||||||
|
microphone: MicrophoneInfo? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
selectedAsFallback: Boolean = false,
|
||||||
|
disabled: Boolean = false,
|
||||||
|
onSelect: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
|
// Copied from Android's [FilledButtonTokens]
|
||||||
|
val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSelect,
|
||||||
|
enabled = !disabled,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp),
|
||||||
|
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
|
||||||
|
) {
|
||||||
|
MicrophoneTypeInfo(
|
||||||
|
type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = microphone?.name
|
||||||
|
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
|
||||||
|
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
|
||||||
|
Text(
|
||||||
|
microphone.deviceInfo.address.toString(),
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
|
||||||
|
color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (selectedAsFallback)
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MicExternalOn,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import android.R
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BluetoothAudio
|
||||||
|
import androidx.compose.material.icons.filled.Memory
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Smartphone
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneTypeInfo(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
type: MicrophoneInfo.MicrophoneType,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (type) {
|
||||||
|
MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
|
||||||
|
MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
|
||||||
|
MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
|
||||||
|
MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LargeFloatingActionButton
|
||||||
|
import androidx.compose.material3.SmallFloatingActionButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PauseResumeButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isPaused: Boolean,
|
||||||
|
onChange: () -> Unit,
|
||||||
|
) {
|
||||||
|
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
|
||||||
|
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
|
||||||
|
|
||||||
|
FloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = if (isPaused) resumeLabel else pauseLabel
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
onClick = onChange,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.formatDuration
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecordingTime(
|
||||||
|
time: Long,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Pulsating {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.Red)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = formatDuration(time),
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveButton(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
) {
|
||||||
|
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.then(modifier),
|
||||||
|
onClick = onSave,
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Save,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,214 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneSelectionButton
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneTypeInfo
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneSelection(
|
||||||
|
audioRecorder: AudioRecorderModel
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showSelection by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
|
||||||
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
|
val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
|
||||||
|
val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
|
||||||
|
val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
|
||||||
|
|
||||||
|
val isTryingToReconnect =
|
||||||
|
audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
|
||||||
|
|
||||||
|
val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
|
||||||
|
listOf(audioRecorder.selectedMicrophone!!)
|
||||||
|
} else {
|
||||||
|
visibleMicrophones
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSelection) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = {
|
||||||
|
showSelection = false
|
||||||
|
},
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(48.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isTryingToReconnect)
|
||||||
|
MessageBox(
|
||||||
|
type = MessageType.INFO,
|
||||||
|
message = stringResource(
|
||||||
|
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
|
||||||
|
audioRecorder.selectedMicrophone?.name ?: "",
|
||||||
|
audioRecorder.selectedMicrophone?.name ?: "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
MicrophoneSelectionButton(
|
||||||
|
selected = audioRecorder.selectedMicrophone == null,
|
||||||
|
selectedAsFallback = isTryingToReconnect,
|
||||||
|
onSelect = {
|
||||||
|
audioRecorder.changeMicrophone(null)
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(shownMicrophones.size) {
|
||||||
|
val microphone = shownMicrophones[it]
|
||||||
|
|
||||||
|
MicrophoneSelectionButton(
|
||||||
|
microphone = microphone,
|
||||||
|
selected = audioRecorder.selectedMicrophone == microphone,
|
||||||
|
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
|
||||||
|
onSelect = {
|
||||||
|
audioRecorder.changeMicrophone(microphone)
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.audioRecorderSettings.showAllMicrophones) {
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(vertical = 32.dp),
|
||||||
|
) {
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Divider(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(hiddenMicrophones.size) {
|
||||||
|
val microphone = hiddenMicrophones[it]
|
||||||
|
|
||||||
|
MicrophoneSelectionButton(
|
||||||
|
microphone = microphone,
|
||||||
|
selected = audioRecorder.selectedMicrophone == microphone,
|
||||||
|
onSelect = {
|
||||||
|
audioRecorder.changeMicrophone(microphone)
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We need to show a placeholder box to keep the the rest aligned correctly
|
||||||
|
Box {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
showSelection = true
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
MicrophoneTypeInfo(
|
||||||
|
type = audioRecorder.selectedMicrophone?.type
|
||||||
|
?: MicrophoneInfo.MicrophoneType.PHONE,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(
|
||||||
|
text = audioRecorder.selectedMicrophone.let {
|
||||||
|
it?.name
|
||||||
|
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (isTryingToReconnect) {
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
|
||||||
|
import app.myzel394.alibi.ui.effects.rememberPrevious
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MicrophoneStatus(
|
||||||
|
audioRecorder: AudioRecorderModel,
|
||||||
|
) {
|
||||||
|
val microphoneStatus = audioRecorder.microphoneStatus
|
||||||
|
val previousStatus = rememberPrevious(microphoneStatus)
|
||||||
|
|
||||||
|
var showMicrophoneStatusDialog by remember {
|
||||||
|
// null = no dialog
|
||||||
|
// `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
|
||||||
|
// `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
|
||||||
|
mutableStateOf<AudioRecorderModel.MicrophoneConnectivityStatus?>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(microphoneStatus) {
|
||||||
|
if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
|
||||||
|
showMicrophoneStatusDialog = microphoneStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
|
||||||
|
MicrophoneDisconnectedDialog(
|
||||||
|
onClose = {
|
||||||
|
showMicrophoneStatusDialog = null
|
||||||
|
},
|
||||||
|
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
|
||||||
|
MicrophoneReconnectedDialog(
|
||||||
|
onClose = {
|
||||||
|
showMicrophoneStatusDialog = null
|
||||||
|
},
|
||||||
|
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MicrophoneSelection(
|
||||||
|
audioRecorder = audioRecorder,
|
||||||
|
)
|
||||||
|
}
|
@ -1,219 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.expandHorizontally
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Save
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.FloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LargeFloatingActionButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.services.RecorderService
|
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
|
||||||
import app.myzel394.alibi.ui.utils.formatDuration
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import java.io.File
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecordingStatus(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
while (true) {
|
|
||||||
now = LocalDateTime.now()
|
|
||||||
delay(900)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show animation when the recording has just started
|
|
||||||
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
|
|
||||||
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
progressVisible = true
|
|
||||||
}
|
|
||||||
|
|
||||||
KeepScreenOn()
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Box {}
|
|
||||||
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Pulsating {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(16.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.Red)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = formatDuration(audioRecorder.recordingTime!!),
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = progressVisible,
|
|
||||||
enter = expandHorizontally(
|
|
||||||
tween(1000)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = audioRecorder.progress,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(300.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showDeleteDialog) {
|
|
||||||
ConfirmDeletionDialog(
|
|
||||||
onDismiss = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
},
|
|
||||||
onConfirm = {
|
|
||||||
showDeleteDialog = false
|
|
||||||
audioRecorder.stopRecording(context, saveAsLastRecording = false)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDeleteDialog = true
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
|
|
||||||
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
|
|
||||||
LargeFloatingActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (audioRecorder.isPaused) {
|
|
||||||
audioRecorder.resumeRecording()
|
|
||||||
} else {
|
|
||||||
audioRecorder.pauseRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
|
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
|
||||||
.alpha(alpha)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
audioRecorder.onRecordingSave()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(stringResource(R.string.ui_audioRecorder_action_save_label))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,147 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.AudioRecorder.organisms
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.DeleteButton
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.PauseResumeButton
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RecordingTime
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveButton
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneSelection
|
||||||
|
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneStatus
|
||||||
|
import app.myzel394.alibi.ui.effects.rememberPrevious
|
||||||
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RecordingStatus(
|
||||||
|
audioRecorder: AudioRecorderModel,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var now by remember { mutableStateOf(LocalDateTime.now()) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
now = LocalDateTime.now()
|
||||||
|
delay(900)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show animation when the recording has just started
|
||||||
|
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
|
||||||
|
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
progressVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
KeepScreenOn()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Box {}
|
||||||
|
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
RecordingTime(audioRecorder.recordingTime!!)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = progressVisible,
|
||||||
|
enter = expandHorizontally(
|
||||||
|
tween(1000)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = audioRecorder.progress,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
DeleteButton(
|
||||||
|
onDelete = {
|
||||||
|
audioRecorder.stopRecording(context, saveAsLastRecording = false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
PauseResumeButton(
|
||||||
|
isPaused = audioRecorder.isPaused,
|
||||||
|
onChange = {
|
||||||
|
if (audioRecorder.isPaused) {
|
||||||
|
audioRecorder.resumeRecording()
|
||||||
|
} else {
|
||||||
|
audioRecorder.pauseRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
SaveButton(
|
||||||
|
onSave = {
|
||||||
|
runCatching {
|
||||||
|
audioRecorder.stopRecording(context)
|
||||||
|
}
|
||||||
|
audioRecorder.onRecordingSave()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MicrophoneStatus(audioRecorder)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.GraphicEq
|
||||||
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.db.AppSettings
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
|
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShowAllMicrophonesTile() {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
val settings = dataStore
|
||||||
|
.data
|
||||||
|
.collectAsState(initial = AppSettings.getDefaultInstance())
|
||||||
|
.value
|
||||||
|
|
||||||
|
fun updateValue(showAllMicrophones: Boolean) {
|
||||||
|
scope.launch {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setAudioRecorderSettings(
|
||||||
|
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
SettingsTile(
|
||||||
|
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
|
||||||
|
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MicExternalOn,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = settings.audioRecorderSettings.showAllMicrophones,
|
||||||
|
onCheckedChange = ::updateValue,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package app.myzel394.alibi.ui.effects
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a dummy MutableState that does not cause render when setting it
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> rememberRef(): MutableState<T?> {
|
||||||
|
// for some reason it always recreated the value with vararg keys,
|
||||||
|
// leaving out the keys as a parameter for remember for now
|
||||||
|
return remember() {
|
||||||
|
object : MutableState<T?> {
|
||||||
|
override var value: T? = null
|
||||||
|
|
||||||
|
override fun component1(): T? = value
|
||||||
|
|
||||||
|
override fun component2(): (T?) -> Unit = { value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> rememberPrevious(
|
||||||
|
current: T,
|
||||||
|
shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b },
|
||||||
|
): T? {
|
||||||
|
val ref = rememberRef<T>()
|
||||||
|
|
||||||
|
// launched after render, so the current render will have the old value anyway
|
||||||
|
SideEffect {
|
||||||
|
if (shouldUpdate(ref.value, current)) {
|
||||||
|
ref.value = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref.value
|
||||||
|
}
|
@ -16,6 +16,7 @@ import app.myzel394.alibi.services.AudioRecorderService
|
|||||||
import app.myzel394.alibi.services.RecorderNotificationHelper
|
import app.myzel394.alibi.services.RecorderNotificationHelper
|
||||||
import app.myzel394.alibi.services.RecorderService
|
import app.myzel394.alibi.services.RecorderService
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
|
||||||
class AudioRecorderModel : ViewModel() {
|
class AudioRecorderModel : ViewModel() {
|
||||||
var recorderState by mutableStateOf(RecorderState.IDLE)
|
var recorderState by mutableStateOf(RecorderState.IDLE)
|
||||||
@ -24,6 +25,8 @@ class AudioRecorderModel : ViewModel() {
|
|||||||
private set
|
private set
|
||||||
var amplitudes by mutableStateOf<List<Int>>(emptyList())
|
var amplitudes by mutableStateOf<List<Int>>(emptyList())
|
||||||
private set
|
private set
|
||||||
|
var selectedMicrophone by mutableStateOf<MicrophoneInfo?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
var onAmplitudeChange: () -> Unit = {}
|
var onAmplitudeChange: () -> Unit = {}
|
||||||
|
|
||||||
@ -46,10 +49,19 @@ class AudioRecorderModel : ViewModel() {
|
|||||||
var onError: () -> Unit = {}
|
var onError: () -> Unit = {}
|
||||||
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
||||||
|
|
||||||
|
var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||||
|
private set
|
||||||
|
|
||||||
|
enum class MicrophoneConnectivityStatus {
|
||||||
|
CONNECTED,
|
||||||
|
DISCONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
recorderService =
|
recorderService =
|
||||||
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder ->
|
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder ->
|
||||||
|
// Update UI when the service changes
|
||||||
recorder.onStateChange = { state ->
|
recorder.onStateChange = { state ->
|
||||||
recorderState = state
|
recorderState = state
|
||||||
}
|
}
|
||||||
@ -64,12 +76,23 @@ class AudioRecorderModel : ViewModel() {
|
|||||||
recorderService!!.createLastRecording()
|
recorderService!!.createLastRecording()
|
||||||
onError()
|
onError()
|
||||||
}
|
}
|
||||||
|
recorder.onSelectedMicrophoneChange = { microphone ->
|
||||||
|
selectedMicrophone = microphone
|
||||||
|
}
|
||||||
|
recorder.onMicrophoneDisconnected = {
|
||||||
|
microphoneStatus = MicrophoneConnectivityStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
recorder.onMicrophoneReconnected = {
|
||||||
|
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||||
|
}
|
||||||
}.also {
|
}.also {
|
||||||
|
// Init UI from the service
|
||||||
it.startRecording()
|
it.startRecording()
|
||||||
|
|
||||||
recorderState = it.state
|
recorderState = it.state
|
||||||
recordingTime = it.recordingTime
|
recordingTime = it.recordingTime
|
||||||
amplitudes = it.amplitudes
|
amplitudes = it.amplitudes
|
||||||
|
selectedMicrophone = it.selectedMicrophone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +106,8 @@ class AudioRecorderModel : ViewModel() {
|
|||||||
recorderState = RecorderState.IDLE
|
recorderState = RecorderState.IDLE
|
||||||
recordingTime = null
|
recordingTime = null
|
||||||
amplitudes = emptyList()
|
amplitudes = emptyList()
|
||||||
|
selectedMicrophone = null
|
||||||
|
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startRecording(context: Context) {
|
fun startRecording(context: Context) {
|
||||||
@ -134,6 +159,14 @@ class AudioRecorderModel : ViewModel() {
|
|||||||
recorderService?.amplitudesAmount = amount
|
recorderService?.amplitudesAmount = amount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
||||||
|
recorderService!!.changeMicrophone(microphone)
|
||||||
|
|
||||||
|
if (microphone == null) {
|
||||||
|
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun bindToService(context: Context) {
|
fun bindToService(context: Context) {
|
||||||
Intent(context, AudioRecorderService::class.java).also { intent ->
|
Intent(context, AudioRecorderService::class.java).also { intent ->
|
||||||
context.bindService(intent, connection, 0)
|
context.bindService(intent, connection, 0)
|
||||||
|
@ -29,7 +29,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
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.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.RecordingStatus
|
import app.myzel394.alibi.ui.components.AudioRecorder.organisms.RecordingStatus
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
|
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.StartRecording
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
import app.myzel394.alibi.ui.enums.Screen
|
||||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||||
|
@ -50,6 +50,7 @@ import app.myzel394.alibi.ui.components.SettingsScreen.atoms.IntervalDurationTil
|
|||||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.MaxDurationTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.OutputFormatTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.OutputFormatTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
|
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.SamplingRateTile
|
||||||
|
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ShowAllMicrophonesTile
|
||||||
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector
|
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector
|
||||||
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
|
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||||
@ -160,6 +161,7 @@ fun SettingsScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 32.dp)
|
.padding(horizontal = 16.dp, vertical = 32.dp)
|
||||||
)
|
)
|
||||||
|
ShowAllMicrophonesTile()
|
||||||
BitrateTile()
|
BitrateTile()
|
||||||
SamplingRateTile()
|
SamplingRateTile()
|
||||||
EncoderTile(snackbarHostState = snackbarHostState)
|
EncoderTile(snackbarHostState = snackbarHostState)
|
||||||
|
@ -0,0 +1,95 @@
|
|||||||
|
package app.myzel394.alibi.ui.utils
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
val ALLOWED_MICROPHONE_TYPES =
|
||||||
|
setOf(
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
|
||||||
|
AudioDeviceInfo.TYPE_USB_DEVICE,
|
||||||
|
AudioDeviceInfo.TYPE_USB_ACCESSORY,
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
|
||||||
|
AudioDeviceInfo.TYPE_IP,
|
||||||
|
AudioDeviceInfo.TYPE_DOCK,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
AudioDeviceInfo.TYPE_DOCK_ANALOG
|
||||||
|
} else {
|
||||||
|
},
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
AudioDeviceInfo.TYPE_BLE_HEADSET
|
||||||
|
} else {
|
||||||
|
},
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
AudioDeviceInfo.TYPE_REMOTE_SUBMIX
|
||||||
|
} else {
|
||||||
|
},
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
AudioDeviceInfo.TYPE_USB_HEADSET
|
||||||
|
} else {
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MicrophoneInfo(
|
||||||
|
val deviceInfo: AudioDeviceInfo,
|
||||||
|
) {
|
||||||
|
val name: String
|
||||||
|
get() = deviceInfo.productName.toString()
|
||||||
|
|
||||||
|
val type: MicrophoneType
|
||||||
|
get() = when (deviceInfo.type) {
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> MicrophoneType.BLUETOOTH
|
||||||
|
AudioDeviceInfo.TYPE_WIRED_HEADSET -> MicrophoneType.WIRED
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_MIC -> MicrophoneType.PHONE
|
||||||
|
else -> MicrophoneType.OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromDeviceInfo(deviceInfo: AudioDeviceInfo): MicrophoneInfo {
|
||||||
|
return MicrophoneInfo(deviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchDeviceMicrophones(context: Context): List<MicrophoneInfo> {
|
||||||
|
return try {
|
||||||
|
val audioManager = context.getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
audioManager.availableCommunicationDevices.map(::fromDeviceInfo)
|
||||||
|
} else {
|
||||||
|
audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS).map(::fromDeviceInfo)
|
||||||
|
}
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.getStackTraceString(error)
|
||||||
|
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter microphones to only show normal ones
|
||||||
|
fun filterMicrophones(microphones: List<MicrophoneInfo>): List<MicrophoneInfo> {
|
||||||
|
return microphones.filter {
|
||||||
|
it.deviceInfo.isSource && (
|
||||||
|
ALLOWED_MICROPHONE_TYPES.contains(it.deviceInfo.type) ||
|
||||||
|
// `type` doesn't seem to be reliably as its sometimes -2147483644 even
|
||||||
|
// for valid microphones
|
||||||
|
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
|
||||||
|
it.deviceInfo.type == -2147483644 &&
|
||||||
|
BluetoothAdapter.checkBluetoothAddress(it.deviceInfo.address) &&
|
||||||
|
it.deviceInfo.productName.isNotBlank()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class MicrophoneType {
|
||||||
|
BLUETOOTH,
|
||||||
|
WIRED,
|
||||||
|
PHONE,
|
||||||
|
OTHER,
|
||||||
|
}
|
||||||
|
}
|
@ -66,6 +66,15 @@
|
|||||||
<string name="ui_audioRecorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string>
|
<string name="ui_audioRecorder_error_recording_description">Alibi encountered an error during recording. Would you like to try saving the recording?</string>
|
||||||
<string name="ui_settings_language_title">Language</string>
|
<string name="ui_settings_language_title">Language</string>
|
||||||
<string name="ui_settings_language_update_label">Change</string>
|
<string name="ui_settings_language_update_label">Change</string>
|
||||||
|
<string name="ui_audioRecorder_info_microphone_deviceMicrophone">Device Microphone</string>
|
||||||
|
<string name="ui_audioRecorder_info_microphone_changeExplanation">The selected microphone will be activated immediately</string>
|
||||||
|
<string name="ui_audioRecorder_error_microphoneDisconnected_title">Microphone disconnected</string>
|
||||||
|
<string name="ui_audioRecorder_error_microphoneDisconnected_message"><xliff:g name="name">%s</xliff:g> disconnected. Alibi will use the default microphone instead. We will automatically switch back to <xliff:g name="nam">%s</xliff:g> once it reconnects.</string>
|
||||||
|
<string name="ui_audioRecorder_error_microphoneReconnected_title">Microphone reconnected</string>
|
||||||
|
<string name="ui_audioRecorder_error_microphoneReconnected_message"><xliff:g name="name">%s</xliff:g> reconnected! Alibi automatically changed the microphone input to it.</string>
|
||||||
|
<string name="ui_settings_option_showAllMicrophones_title">Show hidden microphones</string>
|
||||||
|
<string name="ui_settings_option_showAllMicrophones_description">Show all microphones, including internal ones</string>
|
||||||
|
<string name="ui_audioRecorder_info_microphone_hiddenMicrophones">Hidden Microphones</string>
|
||||||
<string name="ui_settings_option_import_label">Import Settings</string>
|
<string name="ui_settings_option_import_label">Import Settings</string>
|
||||||
<string name="ui_settings_option_export_label">Export Settings</string>
|
<string name="ui_settings_option_export_label">Export Settings</string>
|
||||||
<string name="ui_settings_option_import_dialog_text">Are you sure you want to import these settings? Your current settings will be overwritten!</string>
|
<string name="ui_settings_option_import_dialog_text">Are you sure you want to import these settings? Your current settings will be overwritten!</string>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user