Last active
March 25, 2025 17:24
-
-
Save graffiti75/da204e166039b3468c5555ae2957bc66 to your computer and use it in GitHub Desktop.
AudioWaveformUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
All the files I've used to create this Jetpack Compose UI Component are showed below. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.media.MediaPlayer | |
import br.android.cericatto.audio_waveform_ui.ui.main_screen.MainScreenAction | |
import kotlinx.coroutines.CoroutineScope | |
import kotlinx.coroutines.Job | |
import kotlinx.coroutines.cancel | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.MutableStateFlow | |
import kotlinx.coroutines.flow.StateFlow | |
import kotlinx.coroutines.flow.asStateFlow | |
import kotlinx.coroutines.flow.update | |
import kotlinx.coroutines.isActive | |
import kotlinx.coroutines.launch | |
import java.io.File | |
/** | |
* AudioPlayerState holds all the state needed for the player. | |
*/ | |
data class AudioPlayerState( | |
val isPlaying: Boolean = false, | |
val progress: Float = 0f, | |
val waveformData: WaveformData? = null | |
) | |
/** | |
* AudioPlayerController manages MediaPlayer lifecycle and state updates. | |
*/ | |
class AudioPlayerController( | |
private val onAction: (MainScreenAction) -> Unit, | |
private val file: File, | |
private val coroutineScope: CoroutineScope | |
) { | |
// State management using StateFlow. | |
private val _state = MutableStateFlow(AudioPlayerState()) | |
val state: StateFlow<AudioPlayerState> = _state.asStateFlow() | |
// MediaPlayer instance. | |
private var mediaPlayer: MediaPlayer? = null | |
private var progressJob: Job? = null | |
init { | |
// Initialize MediaPlayer and load waveform data. | |
initializePlayer() | |
loadWaveformData(onAction) | |
} | |
private fun initializePlayer() { | |
mediaPlayer = MediaPlayer().apply { | |
setDataSource(file.path) | |
prepare() | |
setOnCompletionListener { | |
coroutineScope.launch { | |
_state.update { it.copy( | |
isPlaying = false, | |
progress = 0f | |
)} | |
} | |
} | |
} | |
} | |
private fun loadWaveformData(onAction: (MainScreenAction) -> Unit) { | |
coroutineScope.launch { | |
val data = processWaveFile(file) | |
onAction(MainScreenAction.OnAmplitudesLoaded) | |
_state.update { it.copy(waveformData = data) } | |
} | |
} | |
fun togglePlayPause() { | |
mediaPlayer?.let { player -> | |
val currentState = _state.value | |
if (currentState.isPlaying) { | |
pausePlayback() | |
} else { | |
if (currentState.progress >= 1f) { | |
player.seekTo(0) | |
_state.update { it.copy(progress = 0f) } | |
} | |
startPlayback() | |
} | |
} | |
} | |
private fun startPlayback() { | |
mediaPlayer?.start() | |
_state.update { it.copy(isPlaying = true) } | |
startProgressTracking() | |
} | |
private fun pausePlayback() { | |
mediaPlayer?.pause() | |
_state.update { it.copy(isPlaying = false) } | |
progressJob?.cancel() | |
} | |
private fun startProgressTracking() { | |
progressJob?.cancel() | |
progressJob = coroutineScope.launch { | |
while (isActive) { | |
mediaPlayer?.let { player -> | |
val duration = player.duration | |
val progress = player.currentPosition.toFloat() / duration | |
val formattedDuration = duration / 1000 | |
val formattedProgress = (player.currentPosition / 1000) | |
onAction( | |
MainScreenAction.OnProgressChanged( | |
progress = formattedProgress, | |
duration = formattedDuration | |
) | |
) | |
if (formattedProgress >= formattedDuration) { | |
cancel() | |
} | |
_state.update { it.copy(progress = progress) } | |
} | |
delay(16) // Approximately 60 FPS. | |
} | |
} | |
} | |
fun release() { | |
progressJob?.cancel() | |
mediaPlayer?.release() | |
mediaPlayer = null | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.Manifest | |
import androidx.activity.compose.rememberLauncherForActivityResult | |
import androidx.activity.result.contract.ActivityResultContracts | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.AlertDialog | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import br.android.cericatto.audio_waveform_ui.R | |
import br.android.cericatto.audio_waveform_ui.audio.AudioPlayerWithControls | |
import java.io.File | |
@Composable | |
fun RequestRecordAudioPermission(viewModel: MainScreenViewModel) { | |
var permissionGranted by remember { mutableStateOf(false) } | |
var showDialog by remember { mutableStateOf(!viewModel.isRecordAudioPermissionGranted()) } | |
val context = LocalContext.current | |
// Launcher to request the RECORD_AUDIO permission. | |
val launcher = rememberLauncherForActivityResult( | |
contract = ActivityResultContracts.RequestPermission() | |
) { isGranted -> | |
// Handle the permission result. | |
permissionGranted = isGranted | |
if (!isGranted) { | |
showDialog = true | |
} | |
} | |
if (showDialog) { | |
AlertDialog( | |
onDismissRequest = { | |
showDialog = false | |
}, | |
title = { | |
Text( | |
text = context.getString(R.string.record_audio_permission_dialog_title) | |
) | |
}, | |
text = { | |
Text( | |
text = context.getString(R.string.permission_needed) | |
) | |
}, | |
confirmButton = { | |
TextButton( | |
onClick = { | |
showDialog = false | |
launcher.launch(Manifest.permission.RECORD_AUDIO) | |
} | |
) { | |
Text( | |
text = context.getString(R.string.dialog__allow) | |
) | |
} | |
}, | |
dismissButton = { | |
TextButton( | |
onClick = { | |
showDialog = false | |
} | |
) { | |
Text( | |
text = context.getString(R.string.dialog__cancel) | |
) | |
} | |
} | |
) | |
} | |
} | |
@Composable | |
fun AudioPlayer( | |
onAction: (MainScreenAction) -> Unit, | |
state: MainScreenState, | |
file: File, | |
modifier: Modifier | |
) { | |
Column( | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center, | |
modifier = modifier.fillMaxSize() | |
.padding(10.dp) | |
) { | |
AudioPlayerWithControls( | |
onAction = onAction, | |
state = state, | |
file = file | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun AudioPlayerPreview() { | |
val context = LocalContext.current | |
AudioPlayer( | |
onAction = {}, | |
state = MainScreenState(), | |
file = File(context.cacheDir, "audio.wav"), | |
modifier = Modifier | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import kotlinx.coroutines.Dispatchers | |
import kotlinx.coroutines.withContext | |
import java.io.File | |
import java.io.FileInputStream | |
import java.nio.ByteBuffer | |
import java.nio.ByteOrder | |
import kotlin.math.roundToInt | |
/** | |
* Extract Amplitude data from WAV file. | |
* | |
* @param wavFile The file where the data will be extracted. | |
* @param barsPerSecond The desired number of bars to have per second. | |
*/ | |
suspend fun processWaveFile( | |
wavFile: File, | |
barsPerSecond: Int = 1 | |
): WaveformData = withContext(Dispatchers.IO) { | |
getWavFileSizeInKb(wavFile) | |
val duration = getWavDurationInSeconds(wavFile) | |
// Read WAV PCM data and get the Amplitudes. | |
val samples = mutableListOf<Float>() | |
val buffer = ByteArray(2048) | |
var bytesRead: Int | |
val fis = FileInputStream(wavFile) | |
val header = ByteArray(44) | |
fis.read(header) | |
while (fis.read(buffer).also { bytesRead = it } > 0) { | |
for (i in 0 until bytesRead step 2) { | |
if (i + 1 < bytesRead) { | |
val sample = ByteBuffer | |
.wrap(buffer.slice(i..i + 1).toByteArray()) | |
.order(ByteOrder.LITTLE_ENDIAN) | |
.short | |
samples.add(sample / 32768f) | |
} | |
} | |
} | |
fis.close() | |
// Calculate compression based on desired bars per second. | |
val desiredBars = (duration * barsPerSecond).roundToInt() | |
val compressionFactor = samples.size / desiredBars | |
val compressedAmplitudes = samples.chunked(compressionFactor) { chunk -> | |
chunk.maxByOrNull { kotlin.math.abs(it) } ?: 0f | |
} | |
WaveformData(compressedAmplitudes, duration) | |
} | |
suspend fun getWavFileSizeInKb(wavFile: File): Float = withContext(Dispatchers.IO) { | |
val fis = FileInputStream(wavFile) | |
val header = ByteArray(44) // Read the standard 44-byte header. | |
fis.read(header) | |
fis.close() | |
// Verify "RIFF" identifier (bytes 0-3). | |
val riffId = String(header.slice(0..3).toByteArray()) | |
if (riffId != "RIFF") { | |
throw IllegalArgumentException("Not a valid WAV file: 'RIFF' identifier missing") | |
} | |
// Extract ChunkSize from bytes 4-7. | |
val chunkSize = ByteBuffer.wrap(header.slice(4..7).toByteArray()) | |
.order(ByteOrder.LITTLE_ENDIAN) | |
.int | |
// Calculate total file size in bytes. | |
val totalSizeBytes = chunkSize + 8 | |
// Convert to kilobytes (1 KB = 1024 bytes). | |
val totalSizeKb = totalSizeBytes.toFloat() / 1024f | |
return@withContext totalSizeKb | |
} | |
suspend fun getWavDurationInSeconds(wavFile: File): Float = withContext(Dispatchers.IO) { | |
val fis = FileInputStream(wavFile) | |
val headerBuffer = ByteArray(44) | |
fis.read(headerBuffer) | |
// Verify RIFF and WAVE identifiers | |
if (String(headerBuffer.slice(0..3).toByteArray()) != "RIFF") { | |
throw IllegalArgumentException("Not a valid WAV file: 'RIFF' missing") | |
} | |
if (String(headerBuffer.slice(8..11).toByteArray()) != "WAVE") { | |
throw IllegalArgumentException("Not a valid WAV file: 'WAVE' missing") | |
} | |
// Read the entire file into a buffer to find the 'data' chunk | |
val fileSize = wavFile.length().toInt() | |
val fullBuffer = ByteArray(fileSize) | |
fis.close() // Close and reopen to reset position | |
FileInputStream(wavFile).use { it.read(fullBuffer) } | |
// Find the 'data' chunk dynamically. | |
var dataChunkOffset = -1 | |
for (i in 0 until fileSize - 4) { | |
if (String(fullBuffer.slice(i..i + 3).toByteArray()) == "data") { | |
dataChunkOffset = i | |
break | |
} | |
} | |
if (dataChunkOffset == -1) { | |
throw IllegalArgumentException("No 'data' chunk found in WAV file") | |
} | |
// Extract fields from standard positions. | |
val numChannels = ByteBuffer.wrap(headerBuffer.slice(22..23).toByteArray()) | |
.order(ByteOrder.LITTLE_ENDIAN).short.toInt() | |
val sampleRate = ByteBuffer.wrap(headerBuffer.slice(24..27).toByteArray()) | |
.order(ByteOrder.LITTLE_ENDIAN).int | |
val bitsPerSample = ByteBuffer.wrap(headerBuffer.slice(34..35).toByteArray()) | |
.order(ByteOrder.LITTLE_ENDIAN).short.toInt() | |
// Extract dataSize from the 'data' chunk (4 bytes after 'data'). | |
val dataSize = ByteBuffer.wrap( | |
fullBuffer.slice(dataChunkOffset + 4..dataChunkOffset + 7).toByteArray() | |
) | |
.order(ByteOrder.LITTLE_ENDIAN).int | |
// Calculate duration. | |
val bytesPerSample = bitsPerSample / 8 | |
val duration = if (bytesPerSample > 0 && numChannels > 0 && sampleRate > 0) { | |
dataSize.toFloat() / (bytesPerSample * numChannels * sampleRate) | |
} else { | |
0f | |
} | |
return@withContext duration | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.animation.core.Animatable | |
import androidx.compose.animation.core.LinearEasing | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.Canvas | |
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.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material.icons.Icons | |
import androidx.compose.material.icons.filled.Pause | |
import androidx.compose.material.icons.filled.PlayArrow | |
import androidx.compose.material3.CircularProgressIndicator | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.IconButton | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.DisposableEffect | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.shadow | |
import androidx.compose.ui.geometry.CornerRadius | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import br.android.cericatto.audio_waveform_ui.ui.main_screen.MainScreenAction | |
import br.android.cericatto.audio_waveform_ui.ui.main_screen.MainScreenState | |
import br.android.cericatto.audio_waveform_ui.ui.theme.audioBarBackgroundColor | |
import br.android.cericatto.audio_waveform_ui.ui.theme.audioBarChartWave | |
import br.android.cericatto.audio_waveform_ui.ui.theme.audioBarProgressColor | |
import java.io.File | |
/** | |
* Data class to hold both amplitude data and duration. | |
*/ | |
data class WaveformData( | |
val amplitudes: List<Float>, | |
val durationInSeconds: Float | |
) | |
@Composable | |
fun AudioPlayerWithControls( | |
onAction: (MainScreenAction) -> Unit, | |
state: MainScreenState, | |
file: File | |
) { | |
// Create the controller with proper lifecycle scope. | |
val coroutineScope = rememberCoroutineScope() | |
// Remember the controller instance. | |
val controller = remember(file) { | |
AudioPlayerController(onAction, file, coroutineScope) | |
} | |
// Collect the state as a Compose State. | |
val playerState by controller.state.collectAsState() | |
// Cleanup when the composable is disposed. | |
DisposableEffect(controller) { | |
onDispose { | |
controller.release() | |
} | |
} | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 5.dp) // Outer padding to give room for the shadow | |
.shadow( | |
elevation = 5.dp, | |
shape = RoundedCornerShape(20.dp), | |
// ambientColor = audioBarBackgroundShadowColor, // Optional custom ambient shadow | |
// spotColor = audioBarBackgroundShadowColor // Optional custom spot shadow | |
) | |
.background( | |
color = audioBarBackgroundColor, | |
shape = RoundedCornerShape(20.dp) | |
) | |
.height(72.dp) | |
.fillMaxWidth() | |
.padding(8.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center | |
) { | |
if (state.isLoading) { | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier.fillMaxWidth() | |
) { | |
CircularProgressIndicator( | |
color = audioBarProgressColor, | |
strokeWidth = 3.dp, | |
modifier = Modifier | |
.padding(10.dp) | |
.size(30.dp) | |
) | |
} | |
} else { | |
IconButton( | |
onClick = { controller.togglePlayPause() }, | |
modifier = Modifier | |
.padding(end = 8.dp) | |
.background( | |
color = Color.White, | |
shape = RoundedCornerShape(25.dp) | |
) | |
) { | |
Icon( | |
imageVector = if (playerState.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, | |
contentDescription = if (playerState.isPlaying) "Pause" else "Play", | |
tint = audioBarProgressColor, | |
modifier = Modifier.size(40.dp) | |
) | |
} | |
} | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier.weight(1f) | |
) { | |
playerState.waveformData?.let { data -> | |
AudioWaveform( | |
waveformData = data, | |
currentProgress = playerState.progress | |
) | |
} | |
} | |
Box( | |
contentAlignment = Alignment.Center, | |
) { | |
Text( | |
text = formatDuration(state.progress, state.duration), | |
style = TextStyle( | |
fontSize = 20.sp, | |
textAlign = TextAlign.Start, | |
color = Color(0xFF41434F) | |
), | |
modifier = Modifier.padding(start = 5.dp) | |
) | |
} | |
} | |
} | |
@Composable | |
private fun AudioWaveform( | |
waveformData: WaveformData, | |
currentProgress: Float, | |
modifier: Modifier = Modifier, | |
backgroundColor: Color = Color.Transparent, | |
barColor: Color = audioBarChartWave, | |
playedBarColor: Color = audioBarProgressColor, | |
indicatorColor: Color = audioBarProgressColor.copy(alpha = 0.5f) | |
) { | |
val animationProgress = remember { Animatable(0f) } | |
val totalBars = waveformData.amplitudes.size | |
LaunchedEffect(Unit) { | |
animationProgress.animateTo( | |
targetValue = 1f, | |
animationSpec = tween( | |
durationMillis = totalBars * 1000, // Total duration = limit seconds | |
easing = LinearEasing | |
) | |
) | |
} | |
Canvas( | |
modifier = modifier | |
.fillMaxWidth() | |
.height(48.dp) | |
.background( | |
color = backgroundColor, | |
shape = RoundedCornerShape(20.dp) | |
) | |
) { | |
val canvasWidth = size.width | |
val canvasHeight = size.height | |
// Calculate bar width based on canvas width and duration | |
val scaledBarWidth = (canvasWidth / totalBars) * 0.8f // 80% of available space for bars | |
val scaledSpacing = (canvasWidth / totalBars) * 0.2f // 20% for spacing | |
val totalBarWidth = (size.width - (totalBars - 1) * scaledSpacing) / totalBars | |
val progressPerBar = 1f / totalBars | |
val cornerRadius = 15f | |
// Draw all bars | |
waveformData.amplitudes.forEachIndexed { i, amplitude -> | |
val barHeight = canvasHeight * kotlin.math.abs(amplitude) | |
// Determine if this bar has been played | |
val barStartProgress = i * progressPerBar | |
val barEndProgress = (i + 1) * progressPerBar | |
val leftOffset = i * (scaledBarWidth + scaledSpacing) | |
val verticalOffset = (size.height - barHeight) / 2 | |
val fillWidth = when { | |
currentProgress < barStartProgress -> 0f | |
currentProgress >= barEndProgress -> totalBarWidth | |
else -> { | |
val barProgress = (currentProgress - barStartProgress) / progressPerBar | |
totalBarWidth * barProgress | |
} | |
} | |
// Draw background for each amplitude. | |
drawRoundRect( | |
color = barColor, | |
topLeft = Offset(leftOffset, verticalOffset), | |
size = Size(totalBarWidth, barHeight), | |
cornerRadius = CornerRadius(cornerRadius, cornerRadius) | |
) | |
// Draw filled portion. | |
drawRoundRect( | |
color = playedBarColor, | |
topLeft = Offset(leftOffset, verticalOffset), | |
size = Size(fillWidth, barHeight), | |
cornerRadius = CornerRadius(cornerRadius, cornerRadius) | |
) | |
} | |
} | |
} | |
@Preview | |
@Composable | |
private fun AudioPlayerWithControlsPreview() { | |
AudioPlayerWithControls( | |
onAction = {}, | |
state = MainScreenState(), | |
file = File("audio.wav") | |
) | |
} | |
@Preview | |
@Composable | |
private fun AudioWaveformPreview() { | |
AudioWaveform( | |
waveformData = WaveformData( | |
amplitudes = listOf( | |
0.0f, 0.0026550293f, 0.0029296875f, 0.0035095215f, -0.0029296875f, | |
-0.0030212402f, 0.0026550293f, 0.0027770996f, -0.0026550293f, -0.0031738281f, | |
-0.002380371f, 0.009490967f, -0.009613037f, 0.0061035156f, -0.033294678f, | |
-0.09576416f, -0.23312378f, 0.4482727f, 0.48446655f, -0.13415527f, | |
-0.26391602f, -0.41088867f, -0.3270874f, -0.16595459f, 0.2050476f, | |
0.16671753f, 0.22183228f, 0.45864868f, 0.4064331f, 0.23181152f, | |
0.20007324f, 0.52215576f, 0.590271f, 0.58200073f, 0.49273682f, | |
0.28399658f, 0.1461792f, -0.112701416f, -0.13308716f, -0.0987854f, | |
-0.18139648f, -0.13647461f, -0.09335327f, 0.0463562f, -0.035217285f, | |
-0.0087890625f, -0.005645752f, -0.0033569336f, 0.015289307f, -0.0042419434f, | |
0.0021972656f | |
), | |
durationInSeconds = 40f | |
), | |
modifier = Modifier, | |
currentProgress = 0f | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.annotation.SuppressLint | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.SnackbarHost | |
import androidx.compose.material3.SnackbarHostState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.dp | |
import androidx.hilt.navigation.compose.hiltViewModel | |
import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
import java.io.File | |
@Composable | |
fun MainScreenRoot( | |
viewModel: MainScreenViewModel = hiltViewModel() | |
) { | |
val state by viewModel.state.collectAsStateWithLifecycle() | |
val snackbarHostState = remember { SnackbarHostState() } | |
RequestRecordAudioPermission( | |
viewModel = viewModel | |
) | |
MainScreen( | |
onAction = viewModel::onAction, | |
state = state, | |
snackbarHostState = snackbarHostState | |
) | |
} | |
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") | |
@Composable | |
fun MainScreen( | |
onAction: (MainScreenAction) -> Unit, | |
state: MainScreenState, | |
snackbarHostState: SnackbarHostState, | |
) { | |
val context = LocalContext.current | |
val audioFile = File(context.cacheDir, "audio.wav") | |
Scaffold( | |
snackbarHost = { | |
SnackbarHost(hostState = snackbarHostState) | |
}, | |
) { _ -> | |
AudioPlayer( | |
onAction = onAction, | |
state = state, | |
file = audioFile, | |
modifier = Modifier | |
.padding(vertical = 40.dp), | |
) | |
} | |
} | |
@Preview | |
@Composable | |
private fun MainScreenPreview() { | |
MainScreen( | |
onAction = {}, | |
snackbarHostState = SnackbarHostState(), | |
state = MainScreenState() | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
sealed interface MainScreenAction { | |
data object OnAmplitudesLoaded : MainScreenAction | |
data class OnProgressChanged(val progress: Int, val duration: Int) : MainScreenAction | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
data class MainScreenState( | |
val isLoading : Boolean = true, | |
val progress: Int = 0, | |
val duration: Int = 0 | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.Manifest | |
import android.content.Context | |
import android.content.pm.PackageManager | |
import androidx.core.content.ContextCompat | |
import androidx.lifecycle.ViewModel | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableStateFlow | |
import kotlinx.coroutines.flow.StateFlow | |
import kotlinx.coroutines.flow.asStateFlow | |
import kotlinx.coroutines.flow.update | |
import javax.inject.Inject | |
@HiltViewModel | |
class MainScreenViewModel @Inject constructor( | |
private val context: Context | |
): ViewModel() { | |
private val _state = MutableStateFlow(MainScreenState()) | |
val state: StateFlow<MainScreenState> = _state.asStateFlow() | |
/** | |
* Action Methods | |
*/ | |
fun onAction(action: MainScreenAction) { | |
when (action) { | |
is MainScreenAction.OnAmplitudesLoaded -> updateLoading() | |
is MainScreenAction.OnProgressChanged -> updateProgress(action.progress, action.duration) | |
} | |
} | |
/** | |
* State Methods | |
*/ | |
private fun updateLoading() { | |
_state.update { state -> | |
state.copy( | |
isLoading = false | |
) | |
} | |
} | |
private fun updateProgress(progress: Int, duration: Int) { | |
_state.update { state -> | |
state.copy( | |
progress = progress, | |
duration = duration | |
) | |
} | |
} | |
/** | |
* Audio Methods | |
*/ | |
fun isRecordAudioPermissionGranted(): Boolean { | |
return ContextCompat.checkSelfPermission( | |
context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
fun formatDuration(progress: Int, duration: Int) = | |
"${formatTime(progress)}/${formatTime(duration)}" | |
fun formatTime(seconds: Int): String { | |
val minutes = seconds / 60 | |
val secs = seconds % 60 | |
return "$minutes:${secs.toString().padStart(2, '0')}" | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment