mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-19 07:15:25 +02:00
feat: Add basic recording saving functionality
This commit is contained in:
parent
35c59754b5
commit
c8a02567ab
@ -9,6 +9,7 @@ android {
|
|||||||
compileSdk 33
|
compileSdk 33
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
multiDexEnabled true
|
||||||
applicationId "app.myzel394.locationtest"
|
applicationId "app.myzel394.locationtest"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 33
|
targetSdk 33
|
||||||
|
@ -106,7 +106,7 @@ data class AudioRecorderSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setMaxDuration(duration: Long): 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")
|
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.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@ -12,21 +13,30 @@ import android.os.Looper
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import app.myzel394.locationtest.R
|
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.FFmpegKit
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
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.io.File
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
const val INTERVAL_DURATION = 10000L
|
|
||||||
|
|
||||||
class RecorderService: Service() {
|
class RecorderService: Service() {
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
private var mediaRecorder: MediaRecorder? = null
|
private var mediaRecorder: MediaRecorder? = null
|
||||||
private var onError: MediaRecorder.OnErrorListener? = null
|
private var onError: MediaRecorder.OnErrorListener? = null
|
||||||
@ -34,27 +44,30 @@ class RecorderService: Service() {
|
|||||||
|
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
|
|
||||||
|
lateinit var settings: Settings
|
||||||
|
|
||||||
var recordingStart = mutableStateOf<LocalDateTime?>(null)
|
var recordingStart = mutableStateOf<LocalDateTime?>(null)
|
||||||
private set
|
private set
|
||||||
var fileFolder: String? = null
|
var fileFolder: String? = null
|
||||||
private set
|
private set
|
||||||
var bitRate: Int? = null
|
|
||||||
private set
|
|
||||||
var recordingState: RecorderState = RecorderState.IDLE
|
var recordingState: RecorderState = RecorderState.IDLE
|
||||||
private set
|
private set
|
||||||
|
|
||||||
val isRecording: Boolean
|
val isRecording: Boolean
|
||||||
get() = recordingStart.value != null
|
get() = recordingStart.value != null
|
||||||
|
|
||||||
|
val filePaths = mutableListOf<String>()
|
||||||
|
|
||||||
|
var originalRecordingStart: LocalDateTime? = null
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder = binder
|
override fun onBind(p0: Intent?): IBinder = binder
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
Actions.START.toString() -> {
|
Actions.START.toString() -> {
|
||||||
val fileFolder = intent.getStringExtra("fileFolder")
|
fileFolder = getRandomFileFolder(this)
|
||||||
val bitRate = intent.getIntExtra("bitRate", 320000)
|
|
||||||
|
|
||||||
start(fileFolder, bitRate)
|
start()
|
||||||
}
|
}
|
||||||
Actions.STOP.toString() -> stop()
|
Actions.STOP.toString() -> stop()
|
||||||
}
|
}
|
||||||
@ -62,6 +75,15 @@ class RecorderService: Service() {
|
|||||||
return super.onStartCommand(intent, flags, startId)
|
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) {
|
fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) {
|
||||||
this.onError = onError
|
this.onError = onError
|
||||||
}
|
}
|
||||||
@ -70,16 +92,14 @@ class RecorderService: Service() {
|
|||||||
this.onStateChange = onStateChange
|
this.onStateChange = onStateChange
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield all recordings from 0 to counter
|
fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
||||||
fun getRecordingFilePaths() = sequence<String> {
|
val paths = filePaths.joinToString("|")
|
||||||
for (i in 0 until counter) {
|
val outputFile = "$fileFolder/${originalRecordingStart!!.format(DateTimeFormatter.ISO_DATE_TIME)}.${settings.fileExtension}"
|
||||||
yield("$fileFolder/$i.${getFileExtensions()}")
|
|
||||||
}
|
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 command = "-i \"concat:$paths\" -acodec copy $outputFile"
|
||||||
|
|
||||||
val session = FFmpegKit.execute(command)
|
val session = FFmpegKit.execute(command)
|
||||||
@ -98,7 +118,7 @@ class RecorderService: Service() {
|
|||||||
throw Exception("Failed to concatenate audios")
|
throw Exception("Failed to concatenate audios")
|
||||||
}
|
}
|
||||||
|
|
||||||
return outputFile
|
return File(outputFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startNewRecording() {
|
private fun startNewRecording() {
|
||||||
@ -106,6 +126,8 @@ class RecorderService: Service() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteOldRecordings()
|
||||||
|
|
||||||
val newRecorder = createRecorder();
|
val newRecorder = createRecorder();
|
||||||
|
|
||||||
newRecorder.prepare()
|
newRecorder.prepare()
|
||||||
@ -121,24 +143,61 @@ class RecorderService: Service() {
|
|||||||
mediaRecorder = newRecorder
|
mediaRecorder = newRecorder
|
||||||
|
|
||||||
counter++
|
counter++
|
||||||
handler.postDelayed(this::startNewRecording, INTERVAL_DURATION)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun start(fileFolder: String?, bitRate: Int) {
|
private fun deleteOldRecordings() {
|
||||||
this.fileFolder = fileFolder ?: getRandomFileFolder(this)
|
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||||
this.bitRate = bitRate
|
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
|
// Create folder
|
||||||
File(this.fileFolder!!).mkdirs()
|
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
|
showNotification()
|
||||||
recordingStart.value = LocalDateTime.now()
|
startNewRecording()
|
||||||
|
}
|
||||||
showNotification()
|
}
|
||||||
|
|
||||||
startNewRecording()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stop() {
|
private fun stop() {
|
||||||
@ -187,33 +246,12 @@ class RecorderService: Service() {
|
|||||||
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart.value)
|
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart.value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
recordingStart.value!!.toEpochSecond(offset) -
|
recordingStart.value!!.toEpochSecond(offset) -
|
||||||
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
|
||||||
).toInt()
|
).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createRecorder(): MediaRecorder {
|
private fun getFilePath(): String = "$fileFolder/$counter.${settings.fileExtension}"
|
||||||
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()}"
|
|
||||||
|
|
||||||
inner class LocalBinder: Binder() {
|
inner class LocalBinder: Binder() {
|
||||||
fun getService(): RecorderService = this@RecorderService
|
fun getService(): RecorderService = this@RecorderService
|
||||||
@ -231,37 +269,67 @@ class RecorderService: Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 {
|
fun getRandomFileFolder(context: Context): String {
|
||||||
// uuid
|
// uuid
|
||||||
val folder = UUID.randomUUID().toString()
|
val folder = UUID.randomUUID().toString()
|
||||||
|
|
||||||
return "${context.externalCacheDir!!.absolutePath}/$folder"
|
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 android.os.IBinder
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.core.content.ContextCompat
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import app.myzel394.locationtest.services.RecorderService
|
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
|
import app.myzel394.locationtest.ui.enums.Screen
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -35,10 +52,6 @@ fun AudioRecorder(
|
|||||||
navController: NavController,
|
navController: NavController,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { isGranted ->
|
|
||||||
}
|
|
||||||
|
|
||||||
var service by remember { mutableStateOf<RecorderService?>(null) }
|
var service by remember { mutableStateOf<RecorderService?>(null) }
|
||||||
val connection = remember {
|
val connection = remember {
|
||||||
@ -85,51 +98,15 @@ fun AudioRecorder(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {padding ->
|
) {padding ->
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
) {
|
) {
|
||||||
Button(
|
if (isRecording && service != null)
|
||||||
onClick = {
|
RecordingStatus(service = service!!)
|
||||||
// Check audio recording permission
|
else
|
||||||
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
StartRecording(connection = connection, service = service)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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.BitrateTile
|
||||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.EncoderTile
|
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.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.OutputFormatTile
|
||||||
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.SamplingRateTile
|
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.SamplingRateTile
|
||||||
import app.myzel394.locationtest.ui.components.atoms.GlobalSwitch
|
import app.myzel394.locationtest.ui.components.atoms.GlobalSwitch
|
||||||
@ -107,9 +108,8 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
MaxDurationTile()
|
||||||
IntervalDurationTile()
|
IntervalDurationTile()
|
||||||
BitrateTile()
|
|
||||||
SamplingRateTile()
|
|
||||||
AnimatedVisibility(visible = settings.showAdvancedSettings) {
|
AnimatedVisibility(visible = settings.showAdvancedSettings) {
|
||||||
Column {
|
Column {
|
||||||
Divider(
|
Divider(
|
||||||
@ -117,6 +117,8 @@ fun SettingsScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 32.dp)
|
.padding(horizontal = 16.dp, vertical = 32.dp)
|
||||||
)
|
)
|
||||||
|
BitrateTile()
|
||||||
|
SamplingRateTile()
|
||||||
OutputFormatTile()
|
OutputFormatTile()
|
||||||
EncoderTile()
|
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