This commit is contained in:
erik 2025-04-13 19:35:34 -06:00
parent 34326cc8d4
commit 38081f4837
18 changed files with 417 additions and 188 deletions

View File

@ -15,7 +15,7 @@ plugins {
android {
namespace 'com.isolaatti'
compileSdk 34
compileSdk 35
viewBinding {
enabled = true
}

View File

@ -41,7 +41,8 @@ fun AudioPlayer(
durationMs: Long,
onPlay: () -> Unit = {},
onPause: () -> Unit = {},
onSeek: (position: Float) -> Unit = {}
onSeek: (position: Float) -> Unit = {},
options: @Composable () -> Unit = {}
) {
@ -79,11 +80,13 @@ fun AudioPlayer(
onSeek(value)
},
modifier = Modifier.padding(horizontal = 4.dp).weight(1f),
valueRange = 0f..durationMs.toFloat()
valueRange = 0f..durationMs.toFloat().coerceAtLeast(0f)
)
}
Text("${positionMs.milliseconds.clockFormat()}/${durationMs.milliseconds.clockFormat()}")
options()
}
}

View File

@ -19,7 +19,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@ -50,15 +49,14 @@ class AudioRecorderState(
isPlaying: Boolean = false,
isLoading: Boolean = false,
durationSeconds: Long = 0L,
position: Long = 0L,
position: Long = 0L
) {
var isRecording by mutableStateOf(isRecording)
var recordIsStopped by mutableStateOf(recordIsStopped)
var recordIsPaused by mutableStateOf(recordIsPaused)
var isPlaying by mutableStateOf(isPlaying)
var isLoading by mutableStateOf(isLoading)
var durationSeconds by mutableStateOf(durationSeconds)
var durationMilliseconds by mutableStateOf(durationSeconds)
var position by mutableStateOf(position)
fun clearState() {
@ -66,7 +64,7 @@ class AudioRecorderState(
isRecording = false
recordIsPaused = false
isPlaying = false
durationSeconds = 0L
durationMilliseconds = 0L
position = 0L
}
}
@ -74,6 +72,7 @@ class AudioRecorderState(
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AudioRecorder(
modifier: Modifier = Modifier,
onDismiss: () -> Unit = {},
onPlay: () -> Unit = {},
onPause: () -> Unit = {},
@ -82,7 +81,6 @@ fun AudioRecorder(
onStartRecording: (fromPaused: Boolean) -> Unit,
state: AudioRecorderState = AudioRecorderState(),
onSeek: (position: Float) -> Unit = {},
modifier: Modifier = Modifier
) {
@ -174,7 +172,7 @@ fun AudioRecorder(
positionMs = state.position,
isLoading = state.isLoading,
isPlaying = state.isPlaying,
durationMs = state.durationSeconds,
durationMs = state.durationMilliseconds,
onPlay = onPlay,
onPause = onPause,
onSeek = onSeek

View File

@ -6,9 +6,7 @@ import java.time.ZonedDateTime
data class AudiosDto(val data: List<AudioDto>)
data class AudioDto(
val id: String,
val name: String,
val creationTime: ZonedDateTime,
val userId: Int,
val firestoreObjectPath: String,
val userName: String
val durationSeconds: Long
): Serializable

View File

@ -11,14 +11,12 @@ import java.time.ZonedDateTime
data class Audio(
val id: String,
val name: String,
val creationTime: ZonedDateTime,
override val userId: Int,
val userName: String
): Ownable, Playable(), Serializable {
override val uri: Uri get() {
return "${BASE_URL}audios/$id.webm".toUri()
return "${BASE_URL}audios/$id.aac".toUri()
}
override val thumbnail: String get() {
@ -29,10 +27,8 @@ data class Audio(
fun fromDto(audioDto: AudioDto): Audio {
return Audio(
id = audioDto.id,
name = audioDto.name,
creationTime = audioDto.creationTime,
userId = audioDto.userId,
userName = audioDto.userName
)
}
}

View File

@ -1,10 +1,18 @@
package com.isolaatti.audio.player
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.isolaatti.audio.common.domain.Audio
data class AudioPlaybackState(
val currentAudio: Audio? = null,
var isPlaying: Boolean = false,
val duration: Long = 0L,
val progress: Long = 0L
)
class AudioPlaybackState(
currentAudio: Audio? = null,
isPlaying: Boolean = false,
duration: Long = 0L,
progress: Long = 0L
) {
var currentAudio by mutableStateOf(currentAudio)
var isPlaying by mutableStateOf(isPlaying)
var duration by mutableStateOf(duration)
var progress by mutableStateOf(progress)
}

View File

@ -1,6 +1,7 @@
package com.isolaatti.posting.posts.presentation
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -45,11 +46,13 @@ abstract class PostListingViewModelBase : ViewModel() {
val mediaControllerIsAvailable: MutableStateFlow<Boolean> = MutableStateFlow(false)
val audioState: MutableStateFlow<AudioPlaybackState> = MutableStateFlow(AudioPlaybackState())
val audioState = AudioPlaybackState()
fun getLastId(): Long = try { posts.value?.last()?.id ?: 0 } catch (e: NoSuchElementException) { 0 }
val audioRecorderState = AudioRecorderState()
var uploadingAudio: MutableStateFlow<Boolean> = MutableStateFlow(false)
abstract fun getFeed(refresh: Boolean, hashtag: String?)
@ -124,16 +127,4 @@ abstract class PostListingViewModelBase : ViewModel() {
fun onPostAddedAtTheBeginning(post: Post) {
posts.value = listOf(post) + posts.value
}
fun setAudioToPlay(audio: Audio) {
audioState.value = AudioPlaybackState(currentAudio = audio)
}
fun setIsPlaying(isPlaying: Boolean) {
audioState.value = audioState.value.copy(isPlaying = isPlaying)
}
fun updateProgress(progress: Long, duration: Long) {
audioState.value = audioState.value.copy(progress = progress, duration = duration)
}
}

View File

@ -132,7 +132,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
Player.STATE_READY -> {
viewModel.ended = false
viewModel.audioRecorderState.isLoading = false
viewModel.audioRecorderState.durationSeconds = this@CreatePostActivity.mediaController.duration
viewModel.audioRecorderState.durationMilliseconds = this@CreatePostActivity.mediaController.duration
}
Player.STATE_ENDED -> {
viewModel.ended = true

View File

@ -1,11 +1,14 @@
package com.isolaatti.profile.data.remote
import com.isolaatti.audio.common.data.AudioDto
import okhttp3.MultipartBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
interface ProfileApi {
@ -19,4 +22,8 @@ interface ProfileApi {
@POST("EditProfile/RemoveProfileImage")
fun removeCurrentProfilePicture(): Call<Void>
@Multipart
@POST("EditProfile/SetAudioDescription")
fun updateAudioDescription(@Part file: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
}

View File

@ -17,5 +17,5 @@ data class UserProfileDto(
val profileImageId: String?,
val descriptionText: String?,
val descriptionAudioId: String?,
val audio: AudioDto?
val descriptionAudio: AudioDto?
)

View File

@ -3,8 +3,11 @@ package com.isolaatti.profile.data.repository
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.net.toFile
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.images.common.data.remote.ImagesApi
@ -18,10 +21,12 @@ import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.awaitResponse
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import javax.inject.Inject
@ -123,4 +128,27 @@ class ProfileRepositoryImpl @Inject constructor(
emit(Resource.Error(Resource.Error.ErrorType.NetworkError))
}
}
override fun setProfileAudio(audioFile: Uri, duration: Long): Flow<Resource<Audio>> = flow {
emit(Resource.Loading())
try {
val file = audioFile.toFile()
val response = profileApi.updateAudioDescription(
file = MultipartBody.Part.createFormData("audioFile", "profile-audio", file.asRequestBody()),
duration = MultipartBody.Part.createFormData("duration", duration.toString())
).awaitResponse()
if(response.isSuccessful) {
emit(Resource.Success(Audio.fromDto(response.body()!!)))
} else {
emit(Resource.Error(Resource.Error.mapErrorCode(response.code())))
}
} catch(e: IOException) {
emit(Resource.Error(Resource.Error.ErrorType.OtherError))
}
}
}

View File

@ -1,5 +1,7 @@
package com.isolaatti.profile.domain
import android.net.Uri
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.images.common.data.remote.ImageDto
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage
@ -15,4 +17,6 @@ interface ProfileRepository {
fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>>
fun removeProfileImage(): Flow<Resource<Boolean>>
fun setProfileAudio(audioFile: Uri, duration: Long): Flow<Resource<Audio>>
}

View File

@ -46,7 +46,7 @@ data class UserProfile(
profileImageId = userProfileDto.profileImageId,
descriptionText = userProfileDto.descriptionText,
descriptionAudioId = userProfileDto.descriptionAudioId,
descriptionAudio = userProfileDto.audio?.let { Audio.fromDto(it) }
descriptionAudio = userProfileDto.descriptionAudio?.let { Audio.fromDto(it) }
)
}
}

View File

@ -0,0 +1,13 @@
package com.isolaatti.profile.domain.use_case
import android.net.Uri
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.profile.domain.ProfileRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SetProfileAudio @Inject constructor(private val profileRepository: ProfileRepository) {
operator fun invoke(audioUri: Uri, duration: Long): Flow<Resource<Audio>> = profileRepository.setProfileAudio(audioUri, duration)
}

View File

@ -2,10 +2,6 @@ package com.isolaatti.profile.presentation
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
@ -18,6 +14,7 @@ import com.isolaatti.profile.domain.use_case.FollowUser
import com.isolaatti.profile.domain.use_case.GetProfile
import com.isolaatti.profile.domain.use_case.GetProfilePosts
import com.isolaatti.profile.domain.use_case.RemoveProfilePicture
import com.isolaatti.profile.domain.use_case.SetProfileAudio
import com.isolaatti.profile.domain.use_case.SetProfileImage
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
@ -35,7 +32,8 @@ class ProfileViewModel @Inject constructor(
private val getProfilePostsUseCase: GetProfilePosts,
private val setProfileImageUC: SetProfileImage,
private val followUserUC: FollowUser,
private val removeProfilePictureUC: RemoveProfilePicture
private val removeProfilePictureUC: RemoveProfilePicture,
private val setProfileAudio: SetProfileAudio
) : PostListingViewModelBase() {
private val _profile = MutableLiveData<UserProfile>()
val profile: LiveData<UserProfile> get() = _profile
@ -57,8 +55,12 @@ class ProfileViewModel @Inject constructor(
val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false)
val errorProfilePicture: MutableStateFlow<Resource.Error<RemoteImage>?> = MutableStateFlow(null)
val settingUpAudio: MutableStateFlow<Boolean> = MutableStateFlow(false)
var audioDescriptionToUpload: Uri? = null
var ended: Boolean = false
val showAudioRecorder = MutableStateFlow(false)
val setAudioDescriptionError = MutableStateFlow(false)
// runs the lists of "Runnable" one by one and clears list. After this is executed,
// caller should report as handled
@ -214,6 +216,33 @@ class ProfileViewModel @Inject constructor(
}
}
fun setAudioDescriptionProfile() {
viewModelScope.launch {
audioDescriptionToUpload?.let {
setProfileAudio(it, audioRecorderState.durationMilliseconds).onEach { response ->
when(response) {
is Resource.Error -> {
Log.d(TAG, "Error")
setAudioDescriptionError.value = true
uploadingAudio.value = false
}
is Resource.Loading -> {
setAudioDescriptionError.value = false
uploadingAudio.value = true
}
is Resource.Success -> {
Log.d(TAG, "Success audio = ${response.data}")
setAudioDescriptionError.value = false
uploadingAudio.value = false
showAudioRecorder.value = false
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}
fun hideConfirmPictureSheet() {
viewModelScope.launch {
showConfirmChangeProfilePictureBottomSheet.value = false

View File

@ -4,9 +4,7 @@ import android.content.ComponentName
import android.content.Intent
import android.media.MediaRecorder
import android.os.Build
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@ -18,26 +16,37 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState
@ -50,7 +59,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
@ -60,7 +68,9 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
@ -69,10 +79,12 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import androidx.navigation.findNavController
import coil3.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.util.concurrent.ListenableFuture
import com.isolaatti.BuildConfig
@ -90,16 +102,13 @@ import com.isolaatti.common.options_bottom_sheet.presentation.BottomSheetPostOpt
import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment
import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.hashtags.ui.HashtagsPostsActivity
import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.components.PostComponent
import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.posting.posts.presentation.CreatePostContract
import com.isolaatti.posting.posts.ui.CreatePostActivity
import com.isolaatti.posting.posts.ui.PostInfoActivity
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity
import com.isolaatti.profile.domain.entity.UserProfile
import com.isolaatti.profile.presentation.EditProfileContract
import com.isolaatti.profile.presentation.ProfileViewModel
import com.isolaatti.profile.ui.components.ConfirmChangeProfilePictureBottomSheet
@ -111,12 +120,13 @@ import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Calendar
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class ProfileMainFragment : Fragment() {
@ -172,9 +182,6 @@ class ProfileMainFragment : Fragment() {
private lateinit var mediaController: MediaController
private lateinit var mediaRecorder: MediaRecorder
private var mediaRecorder: MediaRecorder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.mediaControllerIsAvailable.value = false
@ -190,36 +197,6 @@ class ProfileMainFragment : Fragment() {
// initialized in onViewCreated
private lateinit var directory: File
private fun startRecording(callback: () -> Unit) {
recordOutputFile = File(directory, "${System.currentTimeMillis()}.3gp")
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder()
mediaRecorder?.run {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
if (Build.VERSION.SDK_INT >= 26) {
setOutputFile(recordOutputFile)
} else {
setOutputFile(recordOutputFile.path)
}
prepare()
start()
callback()
}
}
private fun pauseRecording() {
}
private fun stopRecording() {
}
private fun playRecordedAudio() {
}
private fun pauseRecordedAudio() {
@ -231,15 +208,32 @@ class ProfileMainFragment : Fragment() {
private fun playAudio(descriptionAudio: Audio) {
if(mediaController.currentMediaItem?.mediaId == descriptionAudio.id) {
mediaController.play()
} else {
val mediaItem = MediaItem.Builder()
.setMediaId(descriptionAudio.id)
.setUri(descriptionAudio.uri)
.build()
mediaController.setMediaItem(mediaItem)
mediaController.playWhenReady = true
mediaController.play()
viewModel.audioState.currentAudio = descriptionAudio
}
viewModel.audioState.isPlaying = true
}
private fun pauseAudio(descriptionAudio: Audio) {
mediaController.pause()
viewModel.audioState.isPlaying = false
}
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
@ -257,9 +251,6 @@ class ProfileMainFragment : Fragment() {
val followingState by viewModel.followingState.observeAsState()
val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.collectAsState()
val settingUpAudio by viewModel.settingUpAudio.collectAsState()
val audioPlaybackState by viewModel.audioState.collectAsState()
// use this to show or hide all audio related UI
val mediaControllerIsAvailable by viewModel.mediaControllerIsAvailable.collectAsState()
@ -301,7 +292,7 @@ class ProfileMainFragment : Fragment() {
val newProfilePicture by viewModel.newProfileImage.collectAsState()
val uploadingProfilePicture by viewModel.uploadingProfilePicture.collectAsState()
var showAudioRecorder by remember { mutableStateOf(false) }
val showAudioRecorder by viewModel.showAudioRecorder.collectAsState()
LaunchedEffect(profile) {
showAddPostButton = profile?.isUserItself == true
@ -312,6 +303,20 @@ class ProfileMainFragment : Fragment() {
)
}
LaunchedEffect(viewModel.audioState.isPlaying) {
while(viewModel.audioState.isPlaying) {
delay(500.milliseconds)
viewModel.audioState.duration = mediaController.duration
viewModel.audioState.progress = mediaController.currentPosition
}
}
fun onAddAudioDescription() {
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder()
recordOutputFile = File(directory, "${System.currentTimeMillis()}.3gp")
viewModel.showAudioRecorder.value = true
}
val followingText = when(followingState) {
FollowingState.FollowingThisUser -> {
stringResource(R.string.following_user)
@ -329,9 +334,6 @@ class ProfileMainFragment : Fragment() {
null -> ""
}
IsolaattiTheme {
Scaffold(
topBar = {
@ -430,25 +432,169 @@ class ProfileMainFragment : Fragment() {
}
if(showAudioRecorder) {
ModalBottomSheet(onDismissRequest = { showAudioRecorder = false }) {
AudioRecorder(
onDismiss = {},
onPlay = TODO(),
onPause = TODO(),
onStopRecording = TODO(),
onPauseRecording = TODO(),
onStartRecording = TODO(),
isRecording = TODO(),
recordIsStopped = TODO(),
recordIsPaused = TODO(),
isPlaying = TODO(),
isLoading = TODO(),
durationSeconds = TODO(),
position = TODO(),
onSeek = TODO(),
modifier = TODO()
)
val setAudioRecorderModalBottomSheetState = rememberModalBottomSheetState()
LaunchedEffect(viewModel.audioRecorderState.isPlaying) {
while(viewModel.audioRecorderState.isPlaying) {
delay(500.milliseconds)
viewModel.audioRecorderState.position = mediaController.currentPosition
}
}
fun dismiss() {
viewModel.showAudioRecorder.value = false
// stop playback
mediaController.stop()
mediaController.clearMediaItems()
// delete file
lifecycleScope.launch(Dispatchers.IO) {
if(recordOutputFile.exists()) {
recordOutputFile.delete()
}
}
// release media recorder
mediaRecorder.release()
// clear references
viewModel.audioRecorderState.clearState()
viewModel.audioDescriptionToUpload = null
}
ModalBottomSheet(
onDismissRequest = {
viewModel.showAudioRecorder.value = false
dismiss()
},
sheetState = setAudioRecorderModalBottomSheetState
) {
Text(
text = stringResource(R.string.set_audio_description),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
textAlign = TextAlign.Center,
fontSize = 18.sp
)
val error by viewModel.setAudioDescriptionError.collectAsState()
if(error) {
Box(
Modifier.background(MaterialTheme.colorScheme.errorContainer)
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.error_uploading_audio), modifier = Modifier.padding(8.dp))
}
}
AudioRecorder(
onDismiss = { dismiss() },
onPlay = {
if(mediaController.isCommandAvailable(Player.COMMAND_PREPARE)) {
mediaController.prepare()
}
if(viewModel.ended) {
mediaController.seekTo(0L)
}
mediaController.playWhenReady = true
},
onPause = {
mediaController.pause()
},
onStopRecording = {
mediaRecorder.stop()
val audioUri = recordOutputFile.toUri()
mediaController.setMediaItem(
MediaItem.Builder()
.setUri(audioUri)
.setMediaMetadata(MediaMetadata.Builder()
.setTitle(getString(R.string.just_recorded_audio_title))
.build())
.build()
)
viewModel.audioDescriptionToUpload = audioUri
viewModel.audioRecorderState.recordIsStopped = true
viewModel.audioRecorderState.isRecording = false
viewModel.audioRecorderState.recordIsPaused = false
viewModel.audioRecorderState.durationMilliseconds = mediaController.duration
},
onPauseRecording = {
mediaRecorder.pause()
viewModel.audioRecorderState.recordIsStopped = false
viewModel.audioRecorderState.isRecording = true
viewModel.audioRecorderState.recordIsPaused = true
},
onStartRecording = { fromPause ->
if(fromPause) {
mediaRecorder.resume()
} else {
mediaRecorder.run {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
if (Build.VERSION.SDK_INT >= 26) {
setOutputFile(recordOutputFile)
} else {
setOutputFile(recordOutputFile.path)
}
prepare()
start()
}
}
viewModel.audioRecorderState.recordIsStopped = false
viewModel.audioRecorderState.isRecording = true
viewModel.audioRecorderState.recordIsPaused = false
},
state = viewModel.audioRecorderState,
onSeek = { ms ->
mediaController.seekTo(ms.toLong())
},
modifier = Modifier.fillMaxWidth().padding(8.dp)
)
AnimatedVisibility(visible = viewModel.audioRecorderState.recordIsStopped) {
val uploading by viewModel.uploadingAudio.collectAsState()
Row(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
OutlinedButton(
onClick = {
viewModel.audioRecorderState.clearState()
mediaController.clearMediaItems()
// delete file
lifecycleScope.launch(Dispatchers.IO) {
if(recordOutputFile.exists()) {
recordOutputFile.delete()
}
}
}
) {
Text(stringResource(R.string.reset))
}
Spacer(Modifier.width(8.dp))
Button(
modifier = Modifier.weight(1f),
enabled = !uploading,
onClick = {
viewModel.setAudioDescriptionProfile()
}
) {
if(uploading) {
CircularProgressIndicator(Modifier.size(ButtonDefaults.IconSize))
}
Text(stringResource(R.string.save))
}
}
}
}
}
PullToRefreshBox(
@ -490,74 +636,57 @@ class ProfileMainFragment : Fragment() {
onFollowChange = { follow ->
viewModel.followUser()
},
audioPlayer = {
audioPlayer = { descriptionAudio ->
if(mediaControllerIsAvailable) {
when {
settingUpAudio -> {
AudioRecorder(
modifier = Modifier.fillMaxWidth(),
onDismiss = {
viewModel.settingUpAudio.value = false
},
onPlay = {
playRecordedAudio()
},
onPause = {
pauseRecordedAudio()
},
onStopRecording = {
stopRecording()
},
onPauseRecording = {
pauseRecording()
},
onStartRecording = {
startRecording {
AudioPlayer(
modifier = Modifier.fillMaxWidth(),
positionMs = viewModel.audioState.progress,
isPlaying = viewModel.audioState.isPlaying && (viewModel.audioState.currentAudio == it.descriptionAudio),
durationMs = viewModel.audioState.duration,
isLoading = false,
onPlay = {
playAudio(descriptionAudio)
},
onPause = {
pauseAudio(descriptionAudio)
},
onSeek = { ms ->
seekTo(ms)
},
options = {
if(it.isUserItself) {
var expanded by remember { mutableStateOf(false) }
}
},
state = viewModel.audioRecorderState,
onSeek = {
}
)
}
it.descriptionAudio != null -> {
AudioPlayer(
modifier = Modifier.fillMaxWidth().height(120.dp),
positionMs = 2000L,
isPlaying = false,
durationMs = 30_000L,
isLoading = false,
onPlay = {
playAudio(it.descriptionAudio)
},
onPause = {
pauseAudio(it.descriptionAudio)
},
onSeek = { ms ->
seekTo(ms)
}
)
}
it.isUserItself -> {
Card(Modifier.fillMaxWidth().height(120.dp).padding(16.dp)) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.setup_your_profile_audio))
TextButton(
Box {
FilledTonalIconButton(
modifier = Modifier.padding(4.dp),
onClick = {
viewModel.settingUpAudio.value = true
expanded = true
}
) {
Text("Record audio")
Icon(Icons.Default.MoreVert, stringResource(R.string.audio_options))
}
DropdownMenu(expanded, onDismissRequest = { expanded = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.set_audio_description)) },
onClick = {
expanded = false
onAddAudioDescription()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.remove)) },
onClick = {
expanded = false
}
)
}
}
}
}
}
)
}
},
@ -565,9 +694,7 @@ class ProfileMainFragment : Fragment() {
editProfile.launch(it)
},
onAddAudioDescription = {
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder()
showAudioRecorder = true
onAddAudioDescription()
}
)
}
@ -633,6 +760,31 @@ class ProfileMainFragment : Fragment() {
}
}
private val playerListener: Player.Listener = object: Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
viewModel.audioRecorderState.isPlaying = isPlaying
viewModel.audioState.isPlaying = isPlaying
}
override fun onPlaybackStateChanged(playbackState: Int) {
when(playbackState) {
Player.STATE_READY -> {
viewModel.ended = false
viewModel.audioRecorderState.isLoading = false
viewModel.audioRecorderState.durationMilliseconds = this@ProfileMainFragment.mediaController.duration
}
Player.STATE_ENDED -> {
viewModel.ended = true
}
Player.STATE_BUFFERING -> {
viewModel.audioRecorderState.isLoading = true
}
Player.STATE_IDLE -> {}
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -641,6 +793,8 @@ class ProfileMainFragment : Fragment() {
mediaController = mediaControllerFuture.await()
viewModel.mediaControllerIsAvailable.value = true
mediaController.addListener(playerListener)
// check audios directory
directory = File(requireContext().filesDir, "recordings")

View File

@ -76,6 +76,7 @@ fun ProfileHeader(
loading: Boolean = false,
isOwnUser: Boolean,
followingThisUser: Boolean,
audioPlayer: @Composable (Audio) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier) {
@ -133,11 +134,7 @@ fun ProfileHeader(
OutlinedCard(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.you_have_now_audio))
FilledTonalButton(
onClick = {
}
) {
FilledTonalButton(onClick = onAddAudioDescription) {
Text(stringResource(R.string.set_audio_description))
}
}
@ -145,7 +142,7 @@ fun ProfileHeader(
}
audio != null -> {
audioPlayer(audio)
}
}
@ -191,6 +188,7 @@ fun ProfileHeaderPreview() {
onFollowersClick = {},
onFollowChange = {},
onEditProfileClick = {},
onAddAudioDescription = {}
onAddAudioDescription = {},
audioPlayer = {}
)
}

View File

@ -235,6 +235,8 @@
<string name="uploading_photo">Uploading photo</string>
<string name="you_have_now_audio">You have no audio description</string>
<string name="setup_your_profile_audio">Setup your profile audio</string>
<string name="reset">Reset</string>
<string name="error_uploading_audio">An error ocurred while uploading the audio</string>
<string-array name="report_reasons">
<item>Spam</item>
<item>Explicit content</item>