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 { android {
namespace 'com.isolaatti' namespace 'com.isolaatti'
compileSdk 34 compileSdk 35
viewBinding { viewBinding {
enabled = true enabled = true
} }

View File

@ -41,7 +41,8 @@ fun AudioPlayer(
durationMs: Long, durationMs: Long,
onPlay: () -> Unit = {}, onPlay: () -> Unit = {},
onPause: () -> Unit = {}, onPause: () -> Unit = {},
onSeek: (position: Float) -> Unit = {} onSeek: (position: Float) -> Unit = {},
options: @Composable () -> Unit = {}
) { ) {
@ -79,11 +80,13 @@ fun AudioPlayer(
onSeek(value) onSeek(value)
}, },
modifier = Modifier.padding(horizontal = 4.dp).weight(1f), 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()}") 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -50,15 +49,14 @@ class AudioRecorderState(
isPlaying: Boolean = false, isPlaying: Boolean = false,
isLoading: Boolean = false, isLoading: Boolean = false,
durationSeconds: Long = 0L, durationSeconds: Long = 0L,
position: Long = 0L, position: Long = 0L
) { ) {
var isRecording by mutableStateOf(isRecording) var isRecording by mutableStateOf(isRecording)
var recordIsStopped by mutableStateOf(recordIsStopped) var recordIsStopped by mutableStateOf(recordIsStopped)
var recordIsPaused by mutableStateOf(recordIsPaused) var recordIsPaused by mutableStateOf(recordIsPaused)
var isPlaying by mutableStateOf(isPlaying) var isPlaying by mutableStateOf(isPlaying)
var isLoading by mutableStateOf(isLoading) var isLoading by mutableStateOf(isLoading)
var durationSeconds by mutableStateOf(durationSeconds) var durationMilliseconds by mutableStateOf(durationSeconds)
var position by mutableStateOf(position) var position by mutableStateOf(position)
fun clearState() { fun clearState() {
@ -66,7 +64,7 @@ class AudioRecorderState(
isRecording = false isRecording = false
recordIsPaused = false recordIsPaused = false
isPlaying = false isPlaying = false
durationSeconds = 0L durationMilliseconds = 0L
position = 0L position = 0L
} }
} }
@ -74,6 +72,7 @@ class AudioRecorderState(
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AudioRecorder( fun AudioRecorder(
modifier: Modifier = Modifier,
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
onPlay: () -> Unit = {}, onPlay: () -> Unit = {},
onPause: () -> Unit = {}, onPause: () -> Unit = {},
@ -82,7 +81,6 @@ fun AudioRecorder(
onStartRecording: (fromPaused: Boolean) -> Unit, onStartRecording: (fromPaused: Boolean) -> Unit,
state: AudioRecorderState = AudioRecorderState(), state: AudioRecorderState = AudioRecorderState(),
onSeek: (position: Float) -> Unit = {}, onSeek: (position: Float) -> Unit = {},
modifier: Modifier = Modifier
) { ) {
@ -174,7 +172,7 @@ fun AudioRecorder(
positionMs = state.position, positionMs = state.position,
isLoading = state.isLoading, isLoading = state.isLoading,
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
durationMs = state.durationSeconds, durationMs = state.durationMilliseconds,
onPlay = onPlay, onPlay = onPlay,
onPause = onPause, onPause = onPause,
onSeek = onSeek onSeek = onSeek

View File

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

View File

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

View File

@ -1,10 +1,18 @@
package com.isolaatti.audio.player 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 import com.isolaatti.audio.common.domain.Audio
data class AudioPlaybackState( class AudioPlaybackState(
val currentAudio: Audio? = null, currentAudio: Audio? = null,
var isPlaying: Boolean = false, isPlaying: Boolean = false,
val duration: Long = 0L, duration: Long = 0L,
val progress: 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 package com.isolaatti.posting.posts.presentation
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -45,11 +46,13 @@ abstract class PostListingViewModelBase : ViewModel() {
val mediaControllerIsAvailable: MutableStateFlow<Boolean> = MutableStateFlow(false) 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 } fun getLastId(): Long = try { posts.value?.last()?.id ?: 0 } catch (e: NoSuchElementException) { 0 }
val audioRecorderState = AudioRecorderState() val audioRecorderState = AudioRecorderState()
var uploadingAudio: MutableStateFlow<Boolean> = MutableStateFlow(false)
abstract fun getFeed(refresh: Boolean, hashtag: String?) abstract fun getFeed(refresh: Boolean, hashtag: String?)
@ -124,16 +127,4 @@ abstract class PostListingViewModelBase : ViewModel() {
fun onPostAddedAtTheBeginning(post: Post) { fun onPostAddedAtTheBeginning(post: Post) {
posts.value = listOf(post) + posts.value 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 -> { Player.STATE_READY -> {
viewModel.ended = false viewModel.ended = false
viewModel.audioRecorderState.isLoading = false viewModel.audioRecorderState.isLoading = false
viewModel.audioRecorderState.durationSeconds = this@CreatePostActivity.mediaController.duration viewModel.audioRecorderState.durationMilliseconds = this@CreatePostActivity.mediaController.duration
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
viewModel.ended = true viewModel.ended = true

View File

@ -1,11 +1,14 @@
package com.isolaatti.profile.data.remote package com.isolaatti.profile.data.remote
import com.isolaatti.audio.common.data.AudioDto
import okhttp3.MultipartBody
import retrofit2.Call import retrofit2.Call
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
interface ProfileApi { interface ProfileApi {
@ -19,4 +22,8 @@ interface ProfileApi {
@POST("EditProfile/RemoveProfileImage") @POST("EditProfile/RemoveProfileImage")
fun removeCurrentProfilePicture(): Call<Void> 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 profileImageId: String?,
val descriptionText: String?, val descriptionText: String?,
val descriptionAudioId: 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.content.ContentResolver
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log 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.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.images.common.data.remote.ImagesApi 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 kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.awaitResponse import retrofit2.awaitResponse
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
@ -123,4 +128,27 @@ class ProfileRepositoryImpl @Inject constructor(
emit(Resource.Error(Resource.Error.ErrorType.NetworkError)) 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 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.data.remote.ImageDto
import com.isolaatti.images.common.domain.entity.LocalImage import com.isolaatti.images.common.domain.entity.LocalImage
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
@ -15,4 +17,6 @@ interface ProfileRepository {
fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>> fun updateProfile(newDisplayName: String, newDescription: String): Flow<Resource<Boolean>>
fun removeProfileImage(): 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, profileImageId = userProfileDto.profileImageId,
descriptionText = userProfileDto.descriptionText, descriptionText = userProfileDto.descriptionText,
descriptionAudioId = userProfileDto.descriptionAudioId, 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.net.Uri
import android.util.Log 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.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope 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.GetProfile
import com.isolaatti.profile.domain.use_case.GetProfilePosts import com.isolaatti.profile.domain.use_case.GetProfilePosts
import com.isolaatti.profile.domain.use_case.RemoveProfilePicture 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.profile.domain.use_case.SetProfileImage
import com.isolaatti.utils.Resource import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -35,7 +32,8 @@ class ProfileViewModel @Inject constructor(
private val getProfilePostsUseCase: GetProfilePosts, private val getProfilePostsUseCase: GetProfilePosts,
private val setProfileImageUC: SetProfileImage, private val setProfileImageUC: SetProfileImage,
private val followUserUC: FollowUser, private val followUserUC: FollowUser,
private val removeProfilePictureUC: RemoveProfilePicture private val removeProfilePictureUC: RemoveProfilePicture,
private val setProfileAudio: SetProfileAudio
) : PostListingViewModelBase() { ) : PostListingViewModelBase() {
private val _profile = MutableLiveData<UserProfile>() private val _profile = MutableLiveData<UserProfile>()
val profile: LiveData<UserProfile> get() = _profile val profile: LiveData<UserProfile> get() = _profile
@ -57,8 +55,12 @@ class ProfileViewModel @Inject constructor(
val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false) val uploadingProfilePicture: MutableStateFlow<Boolean> = MutableStateFlow(false)
val errorProfilePicture: MutableStateFlow<Resource.Error<RemoteImage>?> = MutableStateFlow(null) 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, // runs the lists of "Runnable" one by one and clears list. After this is executed,
// caller should report as handled // 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() { fun hideConfirmPictureSheet() {
viewModelScope.launch { viewModelScope.launch {
showConfirmChangeProfilePictureBottomSheet.value = false showConfirmChangeProfilePictureBottomSheet.value = false

View File

@ -4,9 +4,7 @@ import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.media.MediaRecorder
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
@ -18,26 +16,37 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column 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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add 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.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@ -50,7 +59,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 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.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -69,10 +79,12 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle 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.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import androidx.navigation.findNavController import androidx.navigation.findNavController
import coil3.toUri
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.isolaatti.BuildConfig 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.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment
import com.isolaatti.followers.domain.FollowingState import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.hashtags.ui.HashtagsPostsActivity 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.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.components.PostComponent import com.isolaatti.posting.posts.components.PostComponent
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.posting.posts.presentation.CreatePostContract 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.ui.PostInfoActivity
import com.isolaatti.posting.posts.viewer.ui.PostViewerActivity 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.EditProfileContract
import com.isolaatti.profile.presentation.ProfileViewModel import com.isolaatti.profile.presentation.ProfileViewModel
import com.isolaatti.profile.ui.components.ConfirmChangeProfilePictureBottomSheet import com.isolaatti.profile.ui.components.ConfirmChangeProfilePictureBottomSheet
@ -111,12 +120,13 @@ import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.delay
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.Calendar import java.util.Calendar
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint @AndroidEntryPoint
class ProfileMainFragment : Fragment() { class ProfileMainFragment : Fragment() {
@ -172,9 +182,6 @@ class ProfileMainFragment : Fragment() {
private lateinit var mediaController: MediaController private lateinit var mediaController: MediaController
private lateinit var mediaRecorder: MediaRecorder private lateinit var mediaRecorder: MediaRecorder
private var mediaRecorder: MediaRecorder? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
viewModel.mediaControllerIsAvailable.value = false viewModel.mediaControllerIsAvailable.value = false
@ -190,36 +197,6 @@ class ProfileMainFragment : Fragment() {
// initialized in onViewCreated // initialized in onViewCreated
private lateinit var directory: File 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() { private fun pauseRecordedAudio() {
@ -231,15 +208,32 @@ class ProfileMainFragment : Fragment() {
private fun playAudio(descriptionAudio: Audio) { 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) { private fun pauseAudio(descriptionAudio: Audio) {
mediaController.pause()
viewModel.audioState.isPlaying = false
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -257,9 +251,6 @@ class ProfileMainFragment : Fragment() {
val followingState by viewModel.followingState.observeAsState() val followingState by viewModel.followingState.observeAsState()
val posts by viewModel.posts.collectAsState() val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.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 // use this to show or hide all audio related UI
val mediaControllerIsAvailable by viewModel.mediaControllerIsAvailable.collectAsState() val mediaControllerIsAvailable by viewModel.mediaControllerIsAvailable.collectAsState()
@ -301,7 +292,7 @@ class ProfileMainFragment : Fragment() {
val newProfilePicture by viewModel.newProfileImage.collectAsState() val newProfilePicture by viewModel.newProfileImage.collectAsState()
val uploadingProfilePicture by viewModel.uploadingProfilePicture.collectAsState() val uploadingProfilePicture by viewModel.uploadingProfilePicture.collectAsState()
var showAudioRecorder by remember { mutableStateOf(false) } val showAudioRecorder by viewModel.showAudioRecorder.collectAsState()
LaunchedEffect(profile) { LaunchedEffect(profile) {
showAddPostButton = profile?.isUserItself == true 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) { val followingText = when(followingState) {
FollowingState.FollowingThisUser -> { FollowingState.FollowingThisUser -> {
stringResource(R.string.following_user) stringResource(R.string.following_user)
@ -329,9 +334,6 @@ class ProfileMainFragment : Fragment() {
null -> "" null -> ""
} }
IsolaattiTheme { IsolaattiTheme {
Scaffold( Scaffold(
topBar = { topBar = {
@ -430,27 +432,171 @@ class ProfileMainFragment : Fragment() {
} }
if(showAudioRecorder) { if(showAudioRecorder) {
ModalBottomSheet(onDismissRequest = { showAudioRecorder = false }) { val setAudioRecorderModalBottomSheetState = rememberModalBottomSheetState()
AudioRecorder(
onDismiss = {}, LaunchedEffect(viewModel.audioRecorderState.isPlaying) {
onPlay = TODO(), while(viewModel.audioRecorderState.isPlaying) {
onPause = TODO(), delay(500.milliseconds)
onStopRecording = TODO(), viewModel.audioRecorderState.position = mediaController.currentPosition
onPauseRecording = TODO(),
onStartRecording = TODO(),
isRecording = TODO(),
recordIsStopped = TODO(),
recordIsPaused = TODO(),
isPlaying = TODO(),
isLoading = TODO(),
durationSeconds = TODO(),
position = TODO(),
onSeek = TODO(),
modifier = TODO()
)
} }
} }
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( PullToRefreshBox(
isRefreshing, isRefreshing,
onRefresh = { onRefresh = {
@ -490,74 +636,57 @@ class ProfileMainFragment : Fragment() {
onFollowChange = { follow -> onFollowChange = { follow ->
viewModel.followUser() viewModel.followUser()
}, },
audioPlayer = { audioPlayer = { descriptionAudio ->
if(mediaControllerIsAvailable) { if(mediaControllerIsAvailable) {
when {
settingUpAudio -> {
AudioRecorder(
modifier = Modifier.fillMaxWidth(),
onDismiss = {
viewModel.settingUpAudio.value = false
},
onPlay = {
playRecordedAudio()
},
onPause = {
pauseRecordedAudio()
},
onStopRecording = {
stopRecording()
},
onPauseRecording = {
pauseRecording()
},
onStartRecording = {
startRecording {
}
},
state = viewModel.audioRecorderState,
onSeek = {
}
)
}
it.descriptionAudio != null -> {
AudioPlayer( AudioPlayer(
modifier = Modifier.fillMaxWidth().height(120.dp), modifier = Modifier.fillMaxWidth(),
positionMs = 2000L, positionMs = viewModel.audioState.progress,
isPlaying = false, isPlaying = viewModel.audioState.isPlaying && (viewModel.audioState.currentAudio == it.descriptionAudio),
durationMs = 30_000L, durationMs = viewModel.audioState.duration,
isLoading = false, isLoading = false,
onPlay = { onPlay = {
playAudio(it.descriptionAudio) playAudio(descriptionAudio)
}, },
onPause = { onPause = {
pauseAudio(it.descriptionAudio) pauseAudio(descriptionAudio)
}, },
onSeek = { ms -> onSeek = { ms ->
seekTo(ms) seekTo(ms)
},
options = {
if(it.isUserItself) {
var expanded by remember { mutableStateOf(false) }
Box {
FilledTonalIconButton(
modifier = Modifier.padding(4.dp),
onClick = {
expanded = true
}
) {
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
} }
) )
} }
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(
onClick = {
viewModel.settingUpAudio.value = true
}
) {
Text("Record audio")
}
}
} }
} }
} }
)
} }
}, },
@ -565,9 +694,7 @@ class ProfileMainFragment : Fragment() {
editProfile.launch(it) editProfile.launch(it)
}, },
onAddAudioDescription = { onAddAudioDescription = {
mediaRecorder = if (Build.VERSION.SDK_INT >= 31) MediaRecorder(requireContext()) else MediaRecorder() onAddAudioDescription()
showAudioRecorder = true
} }
) )
} }
@ -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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -641,6 +793,8 @@ class ProfileMainFragment : Fragment() {
mediaController = mediaControllerFuture.await() mediaController = mediaControllerFuture.await()
viewModel.mediaControllerIsAvailable.value = true viewModel.mediaControllerIsAvailable.value = true
mediaController.addListener(playerListener)
// check audios directory // check audios directory
directory = File(requireContext().filesDir, "recordings") directory = File(requireContext().filesDir, "recordings")

View File

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

View File

@ -235,6 +235,8 @@
<string name="uploading_photo">Uploading photo</string> <string name="uploading_photo">Uploading photo</string>
<string name="you_have_now_audio">You have no audio description</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="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"> <string-array name="report_reasons">
<item>Spam</item> <item>Spam</item>
<item>Explicit content</item> <item>Explicit content</item>