WIP grabador de audio

This commit is contained in:
erik-everardo 2024-01-31 01:06:45 -06:00
parent 3ff49d73ce
commit 5084fe337d
23 changed files with 525 additions and 86 deletions

2
.idea/navEditor.xml generated
View File

@ -8,7 +8,7 @@
<LayoutPositions> <LayoutPositions>
<option name="myPositions"> <option name="myPositions">
<map> <map>
<entry key="audiosFragment2"> <entry key="audioDraftsFragment">
<value> <value>
<LayoutPositions> <LayoutPositions>
<option name="myPosition"> <option name="myPosition">

View File

@ -4,7 +4,11 @@ import androidx.media3.common.Player
import com.isolaatti.audio.common.data.AudiosApi import com.isolaatti.audio.common.data.AudiosApi
import com.isolaatti.audio.common.data.AudiosRepositoryImpl import com.isolaatti.audio.common.data.AudiosRepositoryImpl
import com.isolaatti.audio.common.domain.AudiosRepository import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudioDraftsRepositoryImpl
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.connectivity.RetrofitClient import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -22,4 +26,14 @@ class Module {
fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository { fun provideAudiosRepository(audiosApi: AudiosApi): AudiosRepository {
return AudiosRepositoryImpl(audiosApi) return AudiosRepositoryImpl(audiosApi)
} }
@Provides
fun provideAudioDraftsDao(database: AppDatabase): AudiosDraftsDao {
return database.audioDrafts()
}
@Provides
fun provideAudioDraftsRepository(audiosDraftsDao: AudiosDraftsDao): AudioDraftsRepository {
return AudioDraftsRepositoryImpl(audiosDraftsDao)
}
} }

View File

@ -10,4 +10,21 @@ abstract class Playable {
* Image url, null indicating no image should be shown * Image url, null indicating no image should be shown
*/ */
abstract val thumbnail: String? abstract val thumbnail: String?
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Playable
if (uri != other.uri) return false
return thumbnail == other.thumbnail
}
override fun hashCode(): Int {
var result = uri.hashCode()
result = 31 * result + (thumbnail?.hashCode() ?: 0)
return result
}
} }

View File

@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
@Entity(tableName = "audio_drafts") @Entity(tableName = "audio_drafts")
data class AudioDraftEntity( data class AudioDraftEntity(
@PrimaryKey val id: Long, @PrimaryKey(autoGenerate = true) val id: Long,
val name: String, val name: String,
val audioLocalPath: String val audioLocalPath: String
) )

View File

@ -0,0 +1,42 @@
package com.isolaatti.audio.drafts.data
import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.File
class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository {
override fun saveAudioDraft(name: String, relativePath: String): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(AudioDraftEntity(0, name, relativePath))
emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId)))
}
override fun getAudioDrafts(): Flow<List<AudioDraft>> = flow {
emit(audiosDraftsDao.getDrafts().map { AudioDraft.fromEntity(it) })
}
override fun deleteDrafts(draftIds: LongArray): Flow<Boolean> = flow {
val drafts = audiosDraftsDao.getAudioDraftsByIds(draftIds)
audiosDraftsDao.deleteDrafts(drafts)
try {
for(draft in drafts) {
File(MyApplication.myApp.applicationContext.filesDir, draft.audioLocalPath).delete()
}
} catch(securityException: SecurityException) {
Log.e("AudioDraftsRepositoryImpl", "Could not delete file\n${securityException.message}")
}
emit(true)
}
override fun renameDraft(draftId: Long, name: String): Flow<Boolean> = flow {
val rowsAffected = audiosDraftsDao.renameDraft(draftId, name)
emit(rowsAffected > 0)
}
}

View File

@ -4,18 +4,25 @@ import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Update
@Dao @Dao
interface AudiosDraftsDao { interface AudiosDraftsDao {
@Insert @Insert
fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long suspend fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long
@Query("SELECT * FROM audio_drafts WHERE id = :draftId") @Query("SELECT * FROM audio_drafts WHERE id = :draftId")
fun getAudioDraftById(draftId: Long): AudioDraftEntity suspend fun getAudioDraftById(draftId: Long): AudioDraftEntity
@Query("SELECT * FROM audio_drafts WHERE id < :before ORDER BY id DESC LIMIT :count") @Query("SELECT * FROM audio_drafts WHERE id in (:draftId)")
fun getDrafts(count: Int, before: Long): List<AudioDraftEntity> suspend fun getAudioDraftsByIds(draftId: LongArray): Array<AudioDraftEntity>
@Query("DELETE FROM audio_drafts WHERE id in (:draftIds)") @Query("SELECT * FROM audio_drafts ORDER BY id DESC")
fun deleteDrafts(draftIds: LongArray) suspend fun getDrafts(): List<AudioDraftEntity>
@Delete
suspend fun deleteDrafts(draft: Array<AudioDraftEntity>)
@Query("UPDATE audio_drafts SET name = :name WHERE id = :id")
suspend fun renameDraft(id: Long, name: String): Int
} }

View File

@ -4,6 +4,7 @@ import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.isolaatti.MyApplication import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Playable import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import java.io.File import java.io.File
data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String) : Playable() { data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String) : Playable() {
@ -15,4 +16,10 @@ data class AudioDraft(val id: Long, val name: String, val localStorageRelativePa
val appFiles = MyApplication.myApp.applicationContext.filesDir val appFiles = MyApplication.myApp.applicationContext.filesDir
return File(appFiles, localStorageRelativePath).toUri() return File(appFiles, localStorageRelativePath).toUri()
} }
companion object {
fun fromEntity(audioDraftEntity: AudioDraftEntity): AudioDraft {
return AudioDraft(audioDraftEntity.id, audioDraftEntity.name, audioDraftEntity.audioLocalPath)
}
}
} }

View File

@ -0,0 +1,14 @@
package com.isolaatti.audio.drafts.domain.repository
import com.isolaatti.audio.drafts.domain.AudioDraft
import kotlinx.coroutines.flow.Flow
interface AudioDraftsRepository {
fun saveAudioDraft(name: String, relativePath: String): Flow<AudioDraft>
fun getAudioDrafts(): Flow<List<AudioDraft>>
fun deleteDrafts(draftIds: LongArray): Flow<Boolean>
fun renameDraft(draftId: Long, name: String): Flow<Boolean>
}

View File

@ -0,0 +1,12 @@
package com.isolaatti.audio.drafts.domain.use_case
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) {
operator fun invoke(name: String, relativePath: String): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath)
}
}

View File

@ -11,6 +11,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -28,7 +29,7 @@ class AudioPlayerConnector(
const val TAG = "AudioPlayerConnector" const val TAG = "AudioPlayerConnector"
} }
private var player: Player? = null private var player: Player? = null
private var audio: Audio? = null private var audio: Playable? = null
private var mediaItem: MediaItem? = null private var mediaItem: MediaItem? = null
private var ended = false private var ended = false
@ -83,12 +84,23 @@ class AudioPlayerConnector(
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
Log.d(TAG, "STATE_ENDED") Log.d(TAG, "STATE_ENDED")
audio?.let { audio?.let {
listeners.forEach { listener -> listener.onPlaying(false, it)} listeners.forEach { listener ->
listener.onPlaying(false, it)
listener.onEnded(it)
}
} }
stopTimer() stopTimer()
ended = true ended = true
} }
Player.STATE_BUFFERING -> {} Player.STATE_BUFFERING -> {
player?.totalBufferedDuration?.let {
val seconds = (it / 1000).toInt()
Log.d(TAG, "Duration $it")
audio?.let {
listeners.forEach { listener -> listener.durationChanged(seconds, it) }
}
}
}
Player.STATE_IDLE -> {} Player.STATE_IDLE -> {}
Player.STATE_READY -> { Player.STATE_READY -> {
Log.d(TAG, "STATE_READY") Log.d(TAG, "STATE_READY")
@ -136,12 +148,13 @@ class AudioPlayerConnector(
} }
Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> { Lifecycle.Event.ON_STOP, Lifecycle.Event.ON_DESTROY -> {
releasePlayer() releasePlayer()
listeners.clear()
} }
else -> {} else -> {}
} }
} }
fun playPauseAudio(audio: Audio) { fun playPauseAudio(audio: Playable) {
// intention is to pause current audio // intention is to pause current audio
if(audio == this.audio && player?.isPlaying == true) { if(audio == this.audio && player?.isPlaying == true) {
@ -162,10 +175,36 @@ class AudioPlayerConnector(
player?.setMediaItem(mediaItem!!) player?.setMediaItem(mediaItem!!)
} }
fun stopPlayback() {
ended = true
player?.pause()
stopTimer()
}
interface Listener { interface Listener {
fun onPlaying(isPlaying: Boolean, audio: Audio) fun onPlaying(isPlaying: Boolean, audio: Playable)
fun isLoading(isLoading: Boolean, audio: Audio) fun isLoading(isLoading: Boolean, audio: Playable)
fun progressChanged(second: Int, audio: Audio) fun progressChanged(second: Int, audio: Playable)
fun durationChanged(duration: Int, audio: Audio) fun durationChanged(duration: Int, audio: Playable)
fun onEnded(audio: Playable)
}
open class DefaultListener() : Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
override fun isLoading(isLoading: Boolean, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) {
}
override fun durationChanged(duration: Int, audio: Playable) {
}
override fun onEnded(audio: Playable) {
}
} }
} }

View File

@ -0,0 +1,52 @@
package com.isolaatti.audio.recorder.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.use_case.SaveAudioDraft
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AudioRecorderViewModel @Inject constructor(
private val saveAudioDraftUC: SaveAudioDraft
) : ViewModel() {
var name: String = ""
set(value) {
field = value
validate()
}
var relativePath = ""
private var audioRecorded = false
val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData()
val canSave: MutableLiveData<Boolean> = MutableLiveData()
private fun validate() {
canSave.value = audioRecorded && name.isNotBlank()
}
fun onAudioRecorded() {
audioRecorded = true
validate()
}
fun onClearAudio() {
audioRecorded = false
validate()
}
fun saveAudioDraft() {
viewModelScope.launch {
saveAudioDraftUC(name, relativePath).onEach {
audioDraft.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -0,0 +1,21 @@
package com.isolaatti.audio.recorder.presentation
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.audio.drafts.ui.AudioDraftsFragment
import com.isolaatti.audio.recorder.ui.AudioRecorderHelperNotesFragment
class RecorderPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int {
return 2
}
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> AudioRecorderHelperNotesFragment()
1 -> AudioDraftsFragment()
else -> Fragment()
}
}
}

View File

@ -2,18 +2,36 @@ package com.isolaatti.audio.recorder.ui
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources.Theme
import android.media.AudioManager
import android.media.MediaRecorder import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.content.res.ResourcesCompat
import androidx.core.net.toUri
import androidx.core.view.get
import androidx.core.widget.doOnTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.dialog.MaterialDialogs
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.audio.recorder.presentation.AudioRecorderViewModel
import com.isolaatti.audio.recorder.presentation.RecorderPagerAdapter
import com.isolaatti.databinding.ActivityAudioRecorderBinding import com.isolaatti.databinding.ActivityAudioRecorderBinding
import com.isolaatti.utils.Resource
import com.isolaatti.utils.clockFormat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -29,13 +47,18 @@ class AudioRecorderActivity : AppCompatActivity() {
companion object { companion object {
const val LOG_TAG = "AudioRecorderActivity" const val LOG_TAG = "AudioRecorderActivity"
const val IN_EXTRA_DRAFT_ID = "in_draft_id"
const val OUT_EXTRA_DRAFT_ID = "out_draft_id"
} }
private lateinit var binding: ActivityAudioRecorderBinding private lateinit var binding: ActivityAudioRecorderBinding
private var audioRecorder: MediaRecorder? = null private var audioRecorder: MediaRecorder? = null
private var recordingPaused = false
private var audioPlayerConnector: AudioPlayerConnector? = null
private val audioUID = UUID.randomUUID() private val viewModel: AudioRecorderViewModel by viewModels()
private lateinit var outputFile: String private lateinit var outputFile: String
private val requestAudioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { private val requestAudioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
@ -46,20 +69,59 @@ class AudioRecorderActivity : AppCompatActivity() {
} }
} }
private val audioPlayerListener = object: AudioPlayerConnector.DefaultListener() {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(isPlaying) {
binding.seekbar.isEnabled = true
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
} else {
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_play_arrow_24, theme)
}
}
override fun onEnded(audio: Playable) {
binding.seekbar.isEnabled = false
binding.seekbar.progress = 0
}
override fun durationChanged(duration: Int, audio: Playable) {
super.durationChanged(duration, audio)
binding.seekbar.max = duration
totalTime = duration
}
override fun progressChanged(second: Int, audio: Playable) {
binding.seekbar.progress = second
setDisplayTime(second, true)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityAudioRecorderBinding.inflate(layoutInflater) binding = ActivityAudioRecorderBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Path where results are stored
File("${filesDir.absolutePath}/audios/").let { File("${filesDir.absolutePath}/audios/").let {
if(!it.isDirectory) { if(!it.isDirectory) {
it.mkdir() it.mkdir()
} }
} }
outputFile = "${filesDir.absolutePath}/audios/${audioUID}.3gp"
binding.seekbar.isEnabled = false
outputFile = "${cacheDir.absolutePath}/audio_recorder.3gp"
setupListeners() setupListeners()
setupObservers()
audioPlayerConnector = AudioPlayerConnector(this)
audioPlayerConnector?.addListener(audioPlayerListener)
lifecycle.addObserver(audioPlayerConnector!!)
} }
private fun checkRecordAudioPermission(): Boolean { private fun checkRecordAudioPermission(): Boolean {
@ -112,25 +174,60 @@ class AudioRecorderActivity : AppCompatActivity() {
pauseRecording() pauseRecording()
} }
binding.acceptButton.setOnClickListener { binding.playPauseButton.setOnClickListener {
acceptRecording() playPauseRecording(object: Playable() {
override val uri: Uri
get() = outputFile.toUri()
override val thumbnail: String?
get() = null
})
} }
binding.playPauseButton.setOnClickListener { binding.inputDraftName.editText?.doOnTextChanged { text, _, _, _ ->
viewModel.name = text.toString()
}
binding.toolbar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.save_draft -> {
viewModel.saveAudioDraft()
true
}
else -> false
}
}
}
private fun setupObservers() {
viewModel.canSave.observe(this) {
binding.toolbar.menu.findItem(R.id.save_draft).isEnabled = it
}
// audio draft is saved!
viewModel.audioDraft.observe(this) {
val result = Intent().apply {
putExtra(OUT_EXTRA_DRAFT_ID, it.id)
}
setResult(RESULT_OK, result)
finish()
} }
} }
// region timer // region timer
private var timer: Job? = null private var timer: Job? = null
private var timerValue = 0 private var timerValue = 0
private var totalTime = 0
private fun setDisplayTime(seconds: Int, showTotalTime: Boolean) { private fun setDisplayTime(seconds: Int, showTotalTime: Boolean) {
val stringBuilder = StringBuilder() binding.time.text = buildString {
append(seconds.clockFormat())
stringBuilder.append(seconds) if(showTotalTime) {
append("/")
binding.time.text = stringBuilder.toString() append(totalTime.clockFormat())
}
}
} }
private fun startTimerRecorder() { private fun startTimerRecorder() {
@ -142,14 +239,6 @@ class AudioRecorderActivity : AppCompatActivity() {
} }
} }
private fun startTimerPlayer() {
timer?.cancel()
timer = CoroutineScope(Dispatchers.Main).launch {
setDisplayTime(timerValue, true)
delay(1000)
timerValue++
}
}
private fun stopTimer() { private fun stopTimer() {
timer?.cancel() timer?.cancel()
@ -159,6 +248,8 @@ class AudioRecorderActivity : AppCompatActivity() {
// region record functions // region record functions
private fun startRecording() { private fun startRecording() {
viewModel.onClearAudio()
recordingPaused = false
audioRecorder = MediaRecorder().apply { audioRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC) setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP) setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
@ -171,6 +262,7 @@ class AudioRecorderActivity : AppCompatActivity() {
timerValue = 0 timerValue = 0
startTimerRecorder() startTimerRecorder()
binding.viewAnimator.displayedChild = 1 binding.viewAnimator.displayedChild = 1
} catch(e: IOException) { } catch(e: IOException) {
Log.e(LOG_TAG, "prepare() failed\n${e.message}") Log.e(LOG_TAG, "prepare() failed\n${e.message}")
} }
@ -187,12 +279,26 @@ class AudioRecorderActivity : AppCompatActivity() {
// shows third state: audio recorded // shows third state: audio recorded
binding.viewAnimator.displayedChild = 2 binding.viewAnimator.displayedChild = 2
recordingPaused = false
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
viewModel.onAudioRecorded()
} }
private fun pauseRecording() { private fun pauseRecording() {
if(recordingPaused) {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
audioRecorder?.resume()
startTimerRecorder()
} else {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_circle_24, theme)
binding.pauseRecording.setIconTintResource(R.color.danger)
audioRecorder?.pause() audioRecorder?.pause()
stopTimer() stopTimer()
} }
recordingPaused = !recordingPaused
}
private fun discardRecording(){ private fun discardRecording(){
File(outputFile).apply { File(outputFile).apply {
@ -203,16 +309,17 @@ class AudioRecorderActivity : AppCompatActivity() {
} }
} }
binding.viewAnimator.displayedChild = 0 binding.viewAnimator.displayedChild = 0
viewModel.onClearAudio()
totalTime = 0
timerValue = 0
binding.time.text = ""
audioPlayerConnector?.stopPlayback()
} }
// end region // end region
private fun acceptRecording() { private fun playPauseRecording(audioDraft: Playable) {
audioPlayerConnector?.playPauseAudio(audioDraft)
}
private fun playPauseRecording() {
} }

View File

@ -0,0 +1,28 @@
package com.isolaatti.audio.recorder.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
class AudioRecorderContract : ActivityResultContract<Long?, Long?>() {
override fun createIntent(context: Context, input: Long?): Intent {
val intent = Intent(context, AudioRecorderActivity::class.java).apply {
if(input != null) {
putExtra(AudioRecorderActivity.IN_EXTRA_DRAFT_ID, input)
}
}
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Long? {
return when(resultCode){
Activity.RESULT_OK -> {
intent?.getLongExtra(AudioRecorderActivity.OUT_EXTRA_DRAFT_ID, 0)?.takeUnless { it == 0L }
}
else -> null
}
}
}

View File

@ -0,0 +1,25 @@
package com.isolaatti.audio.recorder.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
class AudioRecorderHelperNotesFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return LinearLayout(requireContext()).apply {
addView(TextView(requireContext()).apply {
text = "Allow the user to place some info here to be " +
"reading while recording. If this screen is launched from comments, show here the post"
})
}
}
}

View File

@ -9,7 +9,7 @@ import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.settings.data.KeyValueDao import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity import com.isolaatti.settings.data.KeyValueEntity
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class], version = 3) @Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class], version = 4)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun keyValueDao(): KeyValueDao abstract fun keyValueDao(): KeyValueDao
abstract fun userInfoDao(): UserInfoDao abstract fun userInfoDao(): UserInfoDao

View File

@ -11,6 +11,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.isolaatti.audio.recorder.ui.AudioRecorderActivity import com.isolaatti.audio.recorder.ui.AudioRecorderActivity
import com.isolaatti.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.databinding.FragmentMarkdownEditingBinding import com.isolaatti.databinding.FragmentMarkdownEditingBinding
import com.isolaatti.images.image_chooser.ui.ImageChooserContract import com.isolaatti.images.image_chooser.ui.ImageChooserContract
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
@ -22,6 +23,12 @@ class MarkdownEditingFragment : Fragment(){
private val viewModel: CreatePostViewModel by activityViewModels() private val viewModel: CreatePostViewModel by activityViewModels()
private val linkCreatorViewModel: LinkCreatorViewModel by viewModels() private val linkCreatorViewModel: LinkCreatorViewModel by viewModels()
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) { draftId ->
if(draftId != null) {
}
}
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image -> private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
Log.d("MarkdownEditingFragment", "${image?.markdown}") Log.d("MarkdownEditingFragment", "${image?.markdown}")
@ -67,7 +74,7 @@ class MarkdownEditingFragment : Fragment(){
} }
binding.addAudioButton.setOnClickListener { binding.addAudioButton.setOnClickListener {
requireContext().startActivity(Intent(requireContext(), AudioRecorderActivity::class.java)) audioRecorderLauncher.launch(null)
} }
} }

View File

@ -23,6 +23,7 @@ import com.isolaatti.BuildConfig
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.audios_list.ui.AudiosFragment import com.isolaatti.audio.audios_list.ui.AudiosFragment
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.player.AudioPlayerConnector import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.common.CoilImageLoader.imageLoader import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
@ -106,24 +107,28 @@ class ProfileMainFragment : Fragment() {
} }
private val audioPlayerConnectorListener = object: AudioPlayerConnector.Listener { private val audioPlayerConnectorListener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Audio) { override fun onPlaying(isPlaying: Boolean, audio: Playable) {
viewBinding.playButton.icon = AppCompatResources.getDrawable(requireContext(), if(isPlaying) R.drawable.baseline_pause_circle_24 else R.drawable.baseline_play_circle_24) viewBinding.playButton.icon = AppCompatResources.getDrawable(requireContext(), if(isPlaying) R.drawable.baseline_pause_circle_24 else R.drawable.baseline_play_circle_24)
} }
override fun isLoading(isLoading: Boolean, audio: Audio) { override fun isLoading(isLoading: Boolean, audio: Playable) {
viewBinding.playButton.isEnabled = !isLoading viewBinding.playButton.isEnabled = !isLoading
viewBinding.audioProgress.isIndeterminate = isLoading viewBinding.audioProgress.isIndeterminate = isLoading
} }
override fun progressChanged(second: Int, audio: Audio) { override fun progressChanged(second: Int, audio: Playable) {
viewBinding.audioProgress.setProgress(second, true) viewBinding.audioProgress.setProgress(second, true)
} }
override fun durationChanged(duration: Int, audio: Audio) { override fun durationChanged(duration: Int, audio: Playable) {
viewBinding.audioProgress.max = duration viewBinding.audioProgress.max = duration
} }
override fun onEnded(audio: Playable) {
viewBinding.audioProgress.progress = 0
}
} }
private val profileObserver = Observer<UserProfile> { profile -> private val profileObserver = Observer<UserProfile> { profile ->

View File

@ -0,0 +1,8 @@
package com.isolaatti.utils
fun Int.clockFormat(): String {
val minutes = this / 60
val seconds = this % 60
return "${minutes}:${seconds.toString().padStart(2, '0')}"
}

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@ -12,23 +12,41 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:navigationIcon="@drawable/baseline_close_24"/> app:title="@string/audio_recorder"
app:navigationIcon="@drawable/baseline_close_24"
app:menu="@menu/audio_recorder_menu"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_draft_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
android:hint="Name"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/materialCardView2"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/time"
app:layout_constraintTop_toBottomOf="@id/toolbar"
android:layout_margin="24dp" android:layout_margin="24dp"
style="?attr/materialCardViewFilledStyle"> app:layout_constraintBottom_toTopOf="@id/time"
app:layout_constraintTop_toBottomOf="@id/input_draft_name"
tools:layout_editor_absoluteX="24dp">
<androidx.fragment.app.FragmentContainerView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/fragment_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
app:navGraph="@navigation/audio_recorder_navigation"
android:name="androidx.navigation.fragment.NavHostFragment" </androidx.constraintlayout.widget.ConstraintLayout>
app:defaultNavHost="true"/>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@ -36,22 +54,22 @@
android:id="@+id/time" android:id="@+id/time"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/card_controls"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:textAlignment="center"
android:textSize="24sp" android:textSize="24sp"
tools:text="00:00" app:layout_constraintBottom_toTopOf="@id/card_controls"
android:textAlignment="center"/> tools:text="00:00" />
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/card_controls" android:id="@+id/card_controls"
style="?attr/materialCardViewFilledStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="24dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
style="?attr/materialCardViewFilledStyle" app:layout_constraintStart_toStartOf="parent">
android:layout_margin="24dp">
<ViewAnimator <ViewAnimator
android:id="@+id/viewAnimator" android:id="@+id/viewAnimator"
@ -115,43 +133,38 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_margin="8dp"> android:layout_margin="8dp">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/cancel_button" android:id="@+id/cancel_button"
style="@style/Widget.Material3.Button.IconButton" style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:icon="@drawable/baseline_close_24" app:icon="@drawable/baseline_close_24"
app:layout_constraintEnd_toStartOf="@+id/play_pause_button"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<SeekBar
android:id="@+id/seekbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@id/cancel_button"
app:layout_constraintEnd_toStartOf="@id/play_pause_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/play_pause_button" android:id="@+id/play_pause_button"
style="@style/Widget.Material3.Button.IconButton" style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:icon="@drawable/baseline_play_arrow_24" app:icon="@drawable/baseline_play_arrow_24"
app:layout_constraintEnd_toStartOf="@+id/accept_button"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/cancel_button"
app:layout_constraintTop_toTopOf="parent"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/accept_button"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/baseline_check_24"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/play_pause_button"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</ViewAnimator> </ViewAnimator>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,7 +1,14 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Audio drafts"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler" android:id="@+id/recycler"

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/save_draft"
android:title="Save"
app:showAsAction="always"
android:enabled="false"/>
</menu>

View File

@ -1,7 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android" <navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/audio_recorder_navigation"> android:id="@+id/audio_recorder_navigation"
app:startDestination="@id/audioDraftsFragment">
<fragment
android:id="@+id/audioDraftsFragment"
android:name="com.isolaatti.audio.drafts.ui.AudioDraftsFragment"
android:label="AudioDraftsFragment" />
</navigation> </navigation>