diff --git a/app/build.gradle b/app/build.gradle index b6df519..02f7d69 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ plugins { android { namespace 'com.isolaatti' - compileSdk 34 + compileSdk 35 viewBinding { enabled = true } diff --git a/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt b/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt index 574fa45..95ae7db 100644 --- a/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt +++ b/app/src/main/java/com/isolaatti/audio/common/components/AudioPlayer.kt @@ -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() } } diff --git a/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt b/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt index 364b186..a5a3af2 100644 --- a/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt +++ b/app/src/main/java/com/isolaatti/audio/common/components/AudioRecorder.kt @@ -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 diff --git a/app/src/main/java/com/isolaatti/audio/common/data/AudioDto.kt b/app/src/main/java/com/isolaatti/audio/common/data/AudioDto.kt index ff458ff..01e9a11 100644 --- a/app/src/main/java/com/isolaatti/audio/common/data/AudioDto.kt +++ b/app/src/main/java/com/isolaatti/audio/common/data/AudioDto.kt @@ -6,9 +6,7 @@ import java.time.ZonedDateTime data class AudiosDto(val data: List) 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 \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/audio/common/domain/Audio.kt b/app/src/main/java/com/isolaatti/audio/common/domain/Audio.kt index 72c91d9..8f8d7e2 100644 --- a/app/src/main/java/com/isolaatti/audio/common/domain/Audio.kt +++ b/app/src/main/java/com/isolaatti/audio/common/domain/Audio.kt @@ -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 ) } } diff --git a/app/src/main/java/com/isolaatti/audio/player/AudioPlaybackState.kt b/app/src/main/java/com/isolaatti/audio/player/AudioPlaybackState.kt index 5f41360..4703d8b 100644 --- a/app/src/main/java/com/isolaatti/audio/player/AudioPlaybackState.kt +++ b/app/src/main/java/com/isolaatti/audio/player/AudioPlaybackState.kt @@ -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) +} diff --git a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt index 987f0d4..9931a49 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/presentation/PostListingViewModelBase.kt @@ -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 = MutableStateFlow(false) - val audioState: MutableStateFlow = MutableStateFlow(AudioPlaybackState()) + val audioState = AudioPlaybackState() fun getLastId(): Long = try { posts.value?.last()?.id ?: 0 } catch (e: NoSuchElementException) { 0 } val audioRecorderState = AudioRecorderState() + var uploadingAudio: MutableStateFlow = 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) - } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt index 31a1d0e..4a0a689 100644 --- a/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt +++ b/app/src/main/java/com/isolaatti/posting/posts/ui/CreatePostActivity.kt @@ -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 diff --git a/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt b/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt index 1548e04..0876ccd 100644 --- a/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt +++ b/app/src/main/java/com/isolaatti/profile/data/remote/ProfileApi.kt @@ -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 + @Multipart + @POST("EditProfile/SetAudioDescription") + fun updateAudioDescription(@Part file: MultipartBody.Part, @Part duration: MultipartBody.Part): Call + } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt b/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt index b93086a..42153f4 100644 --- a/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt +++ b/app/src/main/java/com/isolaatti/profile/data/remote/UserProfileDto.kt @@ -17,5 +17,5 @@ data class UserProfileDto( val profileImageId: String?, val descriptionText: String?, val descriptionAudioId: String?, - val audio: AudioDto? + val descriptionAudio: AudioDto? ) diff --git a/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt b/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt index 3c9df30..4b08ad7 100644 --- a/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt +++ b/app/src/main/java/com/isolaatti/profile/data/repository/ProfileRepositoryImpl.kt @@ -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> = 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)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt b/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt index 315ce83..b72dcb9 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/ProfileRepository.kt @@ -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> fun removeProfileImage(): Flow> + + fun setProfileAudio(audioFile: Uri, duration: Long): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt index 851441c..6d3a69f 100644 --- a/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt +++ b/app/src/main/java/com/isolaatti/profile/domain/entity/UserProfile.kt @@ -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) } ) } } diff --git a/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileAudio.kt b/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileAudio.kt new file mode 100644 index 0000000..025bd93 --- /dev/null +++ b/app/src/main/java/com/isolaatti/profile/domain/use_case/SetProfileAudio.kt @@ -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> = profileRepository.setProfileAudio(audioUri, duration) +} \ No newline at end of file diff --git a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt index 9927492..5fe2b6b 100644 --- a/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt +++ b/app/src/main/java/com/isolaatti/profile/presentation/ProfileViewModel.kt @@ -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() val profile: LiveData get() = _profile @@ -57,8 +55,12 @@ class ProfileViewModel @Inject constructor( val uploadingProfilePicture: MutableStateFlow = MutableStateFlow(false) val errorProfilePicture: MutableStateFlow?> = MutableStateFlow(null) - val settingUpAudio: MutableStateFlow = 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 diff --git a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt index 1453871..0ba6200 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/ProfileMainFragment.kt @@ -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") diff --git a/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt b/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt index bf3e0f6..2101129 100644 --- a/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt +++ b/app/src/main/java/com/isolaatti/profile/ui/components/ProfileHeader.kt @@ -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 = {} ) } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 250e6d9..090eec9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -235,6 +235,8 @@ Uploading photo You have no audio description Setup your profile audio + Reset + An error ocurred while uploading the audio Spam Explicit content