mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +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.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" />
|
||||
|
||||
@ -39,7 +43,9 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
|
||||
<service
|
||||
android:name=".services.AudioRecorderService"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<!-- Change locale for Android <= 12 -->
|
||||
<service
|
||||
|
@ -87,6 +87,7 @@ data class AudioRecorderSettings(
|
||||
val samplingRate: Int? = null,
|
||||
val outputFormat: Int? = null,
|
||||
val encoder: Int? = null,
|
||||
val showAllMicrophones: Boolean = false,
|
||||
) {
|
||||
fun getOutputFormat(): Int {
|
||||
if (outputFormat != null) {
|
||||
@ -167,7 +168,6 @@ data class AudioRecorderSettings(
|
||||
}
|
||||
|
||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||
println("bitRate: $bitRate")
|
||||
if (bitRate !in 1000..320000) {
|
||||
throw Exception("Bit rate must be between 1000 and 320000")
|
||||
}
|
||||
@ -215,6 +215,10 @@ data class AudioRecorderSettings(
|
||||
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
||||
}
|
||||
|
||||
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||
return copy(showAllMicrophones = showAllMicrophones)
|
||||
}
|
||||
|
||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
||||
return true
|
||||
|
@ -1,26 +1,72 @@
|
||||
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.OnErrorListener
|
||||
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.util.concurrent.Executor
|
||||
|
||||
class AudioRecorderService : IntervalRecorderService() {
|
||||
var amplitudesAmount = 1000
|
||||
var selectedMicrophone: MicrophoneInfo? = null
|
||||
|
||||
var recorder: MediaRecorder? = null
|
||||
private set
|
||||
var onError: () -> Unit = {}
|
||||
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||
var onMicrophoneDisconnected: () -> Unit = {}
|
||||
var onMicrophoneReconnected: () -> Unit = {}
|
||||
|
||||
val filePath: String
|
||||
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 {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.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)
|
||||
setOutputFile(filePath)
|
||||
setOutputFormat(settings!!.outputFormat)
|
||||
@ -39,6 +85,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
clearAudioDevice()
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +97,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
||||
}
|
||||
|
||||
resetRecorder()
|
||||
startAudioDevice()
|
||||
|
||||
try {
|
||||
recorder = newRecorder
|
||||
@ -59,6 +107,12 @@ class AudioRecorderService : IntervalRecorderService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
super.start()
|
||||
|
||||
registerMicrophoneListener()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
super.pause()
|
||||
|
||||
@ -69,6 +123,8 @@ class AudioRecorderService : IntervalRecorderService() {
|
||||
super.stop()
|
||||
|
||||
resetRecorder()
|
||||
selectedMicrophone = null
|
||||
unregisterMicrophoneListener()
|
||||
}
|
||||
|
||||
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
||||
@ -82,4 +138,66 @@ class AudioRecorderService : IntervalRecorderService() {
|
||||
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.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import app.myzel394.alibi.MainActivity
|
||||
import app.myzel394.alibi.NotificationHelper
|
||||
import app.myzel394.alibi.R
|
||||
import app.myzel394.alibi.enums.RecorderState
|
||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||
import kotlinx.serialization.json.Json
|
||||
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.ScheduledExecutorService
|
||||
import java.util.concurrent.TimeUnit
|
||||
@ -115,10 +108,7 @@ abstract class RecorderService : Service() {
|
||||
isPaused = true
|
||||
}
|
||||
|
||||
RecorderState.IDLE -> {
|
||||
stop()
|
||||
onDestroy()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
when (newState) {
|
||||
@ -170,6 +160,7 @@ abstract class RecorderService : Service() {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
stop()
|
||||
changeState(RecorderState.IDLE)
|
||||
|
||||
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.RecorderService
|
||||
import kotlinx.serialization.json.Json
|
||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||
|
||||
class AudioRecorderModel : ViewModel() {
|
||||
var recorderState by mutableStateOf(RecorderState.IDLE)
|
||||
@ -24,6 +25,8 @@ class AudioRecorderModel : ViewModel() {
|
||||
private set
|
||||
var amplitudes by mutableStateOf<List<Int>>(emptyList())
|
||||
private set
|
||||
var selectedMicrophone by mutableStateOf<MicrophoneInfo?>(null)
|
||||
private set
|
||||
|
||||
var onAmplitudeChange: () -> Unit = {}
|
||||
|
||||
@ -46,10 +49,19 @@ class AudioRecorderModel : ViewModel() {
|
||||
var onError: () -> Unit = {}
|
||||
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
||||
|
||||
var microphoneStatus: MicrophoneConnectivityStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||
private set
|
||||
|
||||
enum class MicrophoneConnectivityStatus {
|
||||
CONNECTED,
|
||||
DISCONNECTED
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
recorderService =
|
||||
((service as RecorderService.RecorderBinder).getService() as AudioRecorderService).also { recorder ->
|
||||
// Update UI when the service changes
|
||||
recorder.onStateChange = { state ->
|
||||
recorderState = state
|
||||
}
|
||||
@ -64,12 +76,23 @@ class AudioRecorderModel : ViewModel() {
|
||||
recorderService!!.createLastRecording()
|
||||
onError()
|
||||
}
|
||||
recorder.onSelectedMicrophoneChange = { microphone ->
|
||||
selectedMicrophone = microphone
|
||||
}
|
||||
recorder.onMicrophoneDisconnected = {
|
||||
microphoneStatus = MicrophoneConnectivityStatus.DISCONNECTED
|
||||
}
|
||||
recorder.onMicrophoneReconnected = {
|
||||
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||
}
|
||||
}.also {
|
||||
// Init UI from the service
|
||||
it.startRecording()
|
||||
|
||||
recorderState = it.state
|
||||
recordingTime = it.recordingTime
|
||||
amplitudes = it.amplitudes
|
||||
selectedMicrophone = it.selectedMicrophone
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,6 +106,8 @@ class AudioRecorderModel : ViewModel() {
|
||||
recorderState = RecorderState.IDLE
|
||||
recordingTime = null
|
||||
amplitudes = emptyList()
|
||||
selectedMicrophone = null
|
||||
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||
}
|
||||
|
||||
fun startRecording(context: Context) {
|
||||
@ -134,6 +159,14 @@ class AudioRecorderModel : ViewModel() {
|
||||
recorderService?.amplitudesAmount = amount
|
||||
}
|
||||
|
||||
fun changeMicrophone(microphone: MicrophoneInfo?) {
|
||||
recorderService!!.changeMicrophone(microphone)
|
||||
|
||||
if (microphone == null) {
|
||||
microphoneStatus = MicrophoneConnectivityStatus.CONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
fun bindToService(context: Context) {
|
||||
Intent(context, AudioRecorderService::class.java).also { intent ->
|
||||
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.unit.dp
|
||||
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.enums.Screen
|
||||
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.OutputFormatTile
|
||||
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.atoms.GlobalSwitch
|
||||
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||
@ -160,6 +161,7 @@ fun SettingsScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp)
|
||||
)
|
||||
ShowAllMicrophonesTile()
|
||||
BitrateTile()
|
||||
SamplingRateTile()
|
||||
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_settings_language_title">Language</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_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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user