mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
feat: Add basic recording saving functionality
This commit is contained in:
parent
35c59754b5
commit
c8a02567ab
@ -9,6 +9,7 @@ android {
|
||||
compileSdk 33
|
||||
|
||||
defaultConfig {
|
||||
multiDexEnabled true
|
||||
applicationId "app.myzel394.locationtest"
|
||||
minSdk 24
|
||||
targetSdk 33
|
||||
|
@ -106,7 +106,7 @@ data class AudioRecorderSettings(
|
||||
}
|
||||
|
||||
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
||||
if (duration < 60 * 1000L || duration > 60 * 60 * 1000L) {
|
||||
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
|
||||
throw Exception("Max duration must be between 1 minute and 1 hour")
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ package app.myzel394.locationtest.services
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
@ -12,21 +13,30 @@ import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.myzel394.locationtest.R
|
||||
import app.myzel394.locationtest.dataStore
|
||||
import app.myzel394.locationtest.db.AudioRecorderSettings
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
const val INTERVAL_DURATION = 10000L
|
||||
|
||||
class RecorderService: Service() {
|
||||
private val binder = LocalBinder()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val job = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||
|
||||
private var mediaRecorder: MediaRecorder? = null
|
||||
private var onError: MediaRecorder.OnErrorListener? = null
|
||||
@ -34,27 +44,30 @@ class RecorderService: Service() {
|
||||
|
||||
private var counter = 0
|
||||
|
||||
lateinit var settings: Settings
|
||||
|
||||
var recordingStart = mutableStateOf<LocalDateTime?>(null)
|
||||
private set
|
||||
var fileFolder: String? = null
|
||||
private set
|
||||
var bitRate: Int? = null
|
||||
private set
|
||||
var recordingState: RecorderState = RecorderState.IDLE
|
||||
private set
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = recordingStart.value != null
|
||||
|
||||
val filePaths = mutableListOf<String>()
|
||||
|
||||
var originalRecordingStart: LocalDateTime? = null
|
||||
private set
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
Actions.START.toString() -> {
|
||||
val fileFolder = intent.getStringExtra("fileFolder")
|
||||
val bitRate = intent.getIntExtra("bitRate", 320000)
|
||||
fileFolder = getRandomFileFolder(this)
|
||||
|
||||
start(fileFolder, bitRate)
|
||||
start()
|
||||
}
|
||||
Actions.STOP.toString() -> stop()
|
||||
}
|
||||
@ -62,6 +75,15 @@ class RecorderService: Service() {
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
val progress: Float
|
||||
get() {
|
||||
val start = recordingStart.value ?: return 0f
|
||||
val now = LocalDateTime.now()
|
||||
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
|
||||
|
||||
return duration / (settings.maxDuration / 1000f)
|
||||
}
|
||||
|
||||
fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) {
|
||||
this.onError = onError
|
||||
}
|
||||
@ -70,16 +92,14 @@ class RecorderService: Service() {
|
||||
this.onStateChange = onStateChange
|
||||
}
|
||||
|
||||
// Yield all recordings from 0 to counter
|
||||
fun getRecordingFilePaths() = sequence<String> {
|
||||
for (i in 0 until counter) {
|
||||
yield("$fileFolder/$i.${getFileExtensions()}")
|
||||
}
|
||||
}
|
||||
fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||
val paths = filePaths.joinToString("|")
|
||||
val outputFile = "$fileFolder/${originalRecordingStart!!.format(DateTimeFormatter.ISO_DATE_TIME)}.${settings.fileExtension}"
|
||||
|
||||
if (File(outputFile).exists() && !forceConcatenation) {
|
||||
return File(outputFile)
|
||||
}
|
||||
|
||||
fun concatenateAudios(): String {
|
||||
val paths = getRecordingFilePaths().joinToString("|")
|
||||
val outputFile = "$fileFolder/concatenated.${getFileExtensions()}"
|
||||
val command = "-i \"concat:$paths\" -acodec copy $outputFile"
|
||||
|
||||
val session = FFmpegKit.execute(command)
|
||||
@ -98,7 +118,7 @@ class RecorderService: Service() {
|
||||
throw Exception("Failed to concatenate audios")
|
||||
}
|
||||
|
||||
return outputFile
|
||||
return File(outputFile)
|
||||
}
|
||||
|
||||
private fun startNewRecording() {
|
||||
@ -106,6 +126,8 @@ class RecorderService: Service() {
|
||||
return
|
||||
}
|
||||
|
||||
deleteOldRecordings()
|
||||
|
||||
val newRecorder = createRecorder();
|
||||
|
||||
newRecorder.prepare()
|
||||
@ -121,24 +143,61 @@ class RecorderService: Service() {
|
||||
mediaRecorder = newRecorder
|
||||
|
||||
counter++
|
||||
handler.postDelayed(this::startNewRecording, INTERVAL_DURATION)
|
||||
}
|
||||
|
||||
private fun start(fileFolder: String?, bitRate: Int) {
|
||||
this.fileFolder = fileFolder ?: getRandomFileFolder(this)
|
||||
this.bitRate = bitRate
|
||||
private fun deleteOldRecordings() {
|
||||
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||
val earliestCounter = counter - timeMultiplier
|
||||
|
||||
File(fileFolder!!).listFiles()?.forEach { file ->
|
||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
||||
|
||||
if (fileCounter < earliestCounter) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
filePaths.add(getFilePath())
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFile(getFilePath())
|
||||
setOutputFormat(settings.outputFormat)
|
||||
setAudioEncoder(settings.encoder)
|
||||
setAudioEncodingBitRate(settings.bitRate)
|
||||
setAudioSamplingRate(settings.samplingRate)
|
||||
|
||||
setOnErrorListener { mr, what, extra ->
|
||||
onError?.onError(mr, what, extra)
|
||||
|
||||
this@RecorderService.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun start() {
|
||||
filePaths.clear()
|
||||
// Create folder
|
||||
File(this.fileFolder!!).mkdirs()
|
||||
|
||||
println(this.fileFolder)
|
||||
scope.launch {
|
||||
dataStore.data.collectLatest { preferenceSettings ->
|
||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
||||
recordingState = RecorderState.RECORDING
|
||||
recordingStart.value = LocalDateTime.now()
|
||||
originalRecordingStart = recordingStart.value
|
||||
|
||||
recordingState = RecorderState.RECORDING
|
||||
recordingStart.value = LocalDateTime.now()
|
||||
|
||||
showNotification()
|
||||
|
||||
startNewRecording()
|
||||
showNotification()
|
||||
startNewRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
@ -187,33 +246,12 @@ class RecorderService: Service() {
|
||||
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart.value)
|
||||
|
||||
return (
|
||||
recordingStart.value!!.toEpochSecond(offset) -
|
||||
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
||||
).toInt()
|
||||
recordingStart.value!!.toEpochSecond(offset) -
|
||||
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
||||
).toInt()
|
||||
}
|
||||
|
||||
private fun createRecorder(): MediaRecorder {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaRecorder(this)
|
||||
} else {
|
||||
MediaRecorder()
|
||||
}.apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFile(getFilePath())
|
||||
setOutputFormat(getOutputFormat())
|
||||
setAudioEncoder(getAudioEncoder())
|
||||
setAudioEncodingBitRate(bitRate!!)
|
||||
setAudioSamplingRate(getAudioSamplingRate())
|
||||
|
||||
setOnErrorListener { mr, what, extra ->
|
||||
onError?.onError(mr, what, extra)
|
||||
|
||||
this@RecorderService.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFilePath() = "${fileFolder}/${counter}.${getFileExtensions()}"
|
||||
private fun getFilePath(): String = "$fileFolder/$counter.${settings.fileExtension}"
|
||||
|
||||
inner class LocalBinder: Binder() {
|
||||
fun getService(): RecorderService = this@RecorderService
|
||||
@ -231,37 +269,67 @@ class RecorderService: Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getOutputFormat(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.OutputFormat.AAC_ADTS
|
||||
else
|
||||
MediaRecorder.OutputFormat.THREE_GPP
|
||||
|
||||
fun getFileExtensions(): String =
|
||||
when(getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
else -> throw Exception("Unknown output format")
|
||||
}
|
||||
|
||||
fun getAudioSamplingRate(): Int =
|
||||
when(getOutputFormat()) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> 44100
|
||||
else -> throw Exception("Unknown output format")
|
||||
}
|
||||
|
||||
fun getAudioEncoder(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
MediaRecorder.AudioEncoder.AAC
|
||||
else
|
||||
MediaRecorder.AudioEncoder.AMR_NB
|
||||
|
||||
fun getRandomFileFolder(context: Context): String {
|
||||
// uuid
|
||||
val folder = UUID.randomUUID().toString()
|
||||
|
||||
return "${context.externalCacheDir!!.absolutePath}/$folder"
|
||||
}
|
||||
|
||||
fun startService(context: Context, connection: ServiceConnection?) {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = RecorderService.Actions.START.toString()
|
||||
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
if (connection != null) {
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopService(context: Context) {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = RecorderService.Actions.STOP.toString()
|
||||
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Settings(
|
||||
val maxDuration: Long,
|
||||
val intervalDuration: Long,
|
||||
val bitRate: Int,
|
||||
val samplingRate: Int,
|
||||
val outputFormat: Int,
|
||||
val encoder: Int,
|
||||
) {
|
||||
val fileExtension: String
|
||||
get() = when(outputFormat) {
|
||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||
else -> "raw"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
||||
return Settings(
|
||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
||||
bitRate = audioRecorderSettings.bitRate,
|
||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
||||
encoder = audioRecorderSettings.getEncoder(),
|
||||
maxDuration = audioRecorderSettings.maxDuration,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,120 @@
|
||||
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
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.height
|
||||
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.Save
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.unit.dp
|
||||
import app.myzel394.locationtest.services.RecorderService
|
||||
import app.myzel394.locationtest.ui.components.atoms.Pulsating
|
||||
import app.myzel394.locationtest.ui.utils.formatDuration
|
||||
import app.myzel394.locationtest.ui.utils.rememberFileSaverDialog
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Composable
|
||||
fun RecordingStatus(
|
||||
service: RecorderService,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val saveFile = rememberFileSaverDialog("audio/*")
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Forces real time update for the text
|
||||
val transition = rememberInfiniteTransition()
|
||||
val forceUpdateValue by transition.animateFloat(
|
||||
initialValue = .999999f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
val distance = Duration.between(service.recordingStart.value, LocalDateTime.now()).toMillis()
|
||||
|
||||
Pulsating {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Red)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = formatDuration(distance),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier.alpha(forceUpdateValue)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = service.progress,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.alpha(forceUpdateValue)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
RecorderService.stopService(context)
|
||||
|
||||
saveFile(service.concatenateFiles())
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text("Save Recording")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
RecorderService.stopService(context)
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(),
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,94 @@
|
||||
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
|
||||
|
||||
import android.content.ServiceConnection
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.Mic
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.myzel394.locationtest.services.RecorderService
|
||||
import app.myzel394.locationtest.ui.utils.rememberFileSaverDialog
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun StartRecording(
|
||||
connection: ServiceConnection,
|
||||
service: RecorderService? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val saveFile = rememberFileSaverDialog("audio/*")
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box {}
|
||||
Button(
|
||||
onClick = {
|
||||
RecorderService.startService(context, connection)
|
||||
},
|
||||
modifier = Modifier
|
||||
.semantics {
|
||||
contentDescription = "Start recording"
|
||||
}
|
||||
.size(200.dp)
|
||||
.clip(shape = CircleShape),
|
||||
colors = ButtonDefaults.outlinedButtonColors(),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||
Text(
|
||||
"Start Recording",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (service?.originalRecordingStart != null)
|
||||
Button(
|
||||
onClick = {
|
||||
saveFile(service.concatenateFiles())
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||
Text("Save Recording from ${service.originalRecordingStart!!.format(DateTimeFormatter.ISO_DATE_TIME)}")
|
||||
}
|
||||
else
|
||||
Box {}
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package app.myzel394.locationtest.ui.components.atoms
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
|
||||
@Composable
|
||||
fun Pulsating(content: @Composable () -> Unit) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
)
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.alpha(alpha)) {
|
||||
content()
|
||||
}
|
||||
}
|
@ -9,24 +9,41 @@ import android.media.MediaPlayer
|
||||
import android.os.IBinder
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import app.myzel394.locationtest.services.RecorderService
|
||||
import app.myzel394.locationtest.ui.components.AudioRecorder.atoms.RecordingStatus
|
||||
import app.myzel394.locationtest.ui.components.AudioRecorder.atoms.StartRecording
|
||||
import app.myzel394.locationtest.ui.enums.Screen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -35,10 +52,6 @@ fun AudioRecorder(
|
||||
navController: NavController,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
}
|
||||
|
||||
var service by remember { mutableStateOf<RecorderService?>(null) }
|
||||
val connection = remember {
|
||||
@ -85,51 +98,15 @@ fun AudioRecorder(
|
||||
)
|
||||
},
|
||||
) {padding ->
|
||||
Row(
|
||||
modifier = Modifier.padding(padding),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
// Check audio recording permission
|
||||
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
launcher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
|
||||
return@Button
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = RecorderService.Actions.STOP.toString()
|
||||
|
||||
context.startService(intent)
|
||||
}
|
||||
} else {
|
||||
Intent(context, RecorderService::class.java).also { intent ->
|
||||
intent.action = RecorderService.Actions.START.toString()
|
||||
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = if (isRecording) "Stop" else "Start")
|
||||
}
|
||||
if (!isRecording && service != null)
|
||||
Button(
|
||||
onClick = {
|
||||
val path = service!!.concatenateAudios()
|
||||
|
||||
val player = MediaPlayer().apply {
|
||||
setDataSource(path)
|
||||
prepare()
|
||||
}
|
||||
|
||||
player.start()
|
||||
},
|
||||
) {
|
||||
Text(text = "Convert")
|
||||
}
|
||||
if (isRecording && service != null)
|
||||
RecordingStatus(service = service!!)
|
||||
else
|
||||
StartRecording(connection = connection, service = service)
|
||||
}
|
||||
}
|
||||
}
|
@ -41,6 +41,7 @@ import app.myzel394.locationtest.db.AudioRecorderSettings
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.BitrateTile
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.EncoderTile
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.IntervalDurationTile
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.MaxDurationTile
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.OutputFormatTile
|
||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.SamplingRateTile
|
||||
import app.myzel394.locationtest.ui.components.atoms.GlobalSwitch
|
||||
@ -107,9 +108,8 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
MaxDurationTile()
|
||||
IntervalDurationTile()
|
||||
BitrateTile()
|
||||
SamplingRateTile()
|
||||
AnimatedVisibility(visible = settings.showAdvancedSettings) {
|
||||
Column {
|
||||
Divider(
|
||||
@ -117,6 +117,8 @@ fun SettingsScreen(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 32.dp)
|
||||
)
|
||||
BitrateTile()
|
||||
SamplingRateTile()
|
||||
OutputFormatTile()
|
||||
EncoderTile()
|
||||
}
|
||||
|
@ -0,0 +1,30 @@
|
||||
package app.myzel394.locationtest.ui.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
// From @Bnyro
|
||||
object PermissionHelper {
|
||||
fun checkPermissions(context: Context, permissions: Array<String>): Boolean {
|
||||
permissions.forEach {
|
||||
if (!hasPermission(context, it)) {
|
||||
ActivityCompat.requestPermissions(
|
||||
context as Activity,
|
||||
arrayOf(it),
|
||||
1
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun hasPermission(context: Context, permission: String): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
permission
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user