This commit is contained in:
erik-everardo 2025-03-15 17:31:56 -06:00
parent b019c79bcb
commit eb6026e1d9
16 changed files with 303 additions and 83 deletions

View File

@ -159,7 +159,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.5'
implementation 'androidx.compose.runtime:runtime-livedata' implementation 'androidx.compose.runtime:runtime-livedata'
implementation("com.google.accompanist:accompanist-permissions:0.36.0") implementation("com.google.accompanist:accompanist-permissions:0.36.0")
implementation "androidx.work:work-runtime-ktx:2.10.0"
} }

View File

@ -20,6 +20,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -48,7 +49,7 @@ fun AudioPlayer(
Card(modifier = modifier) { Card(modifier = modifier) {
Row(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = {if(isPlaying) onPause() else onPlay()}) { IconButton(onClick = {if(isPlaying) onPause() else onPlay()}) {
if(isPlaying) { if(isPlaying) {
Icon(painterResource(R.drawable.baseline_pause_24), null) Icon(painterResource(R.drawable.baseline_pause_24), null)

View File

@ -19,8 +19,10 @@ 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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -40,6 +42,35 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class AudioRecorderState(
isRecording: Boolean = false,
recordIsStopped: Boolean = false,
recordIsPaused: Boolean = false,
isPlaying: Boolean = false,
isLoading: Boolean = false,
durationSeconds: 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 position by mutableStateOf(position)
fun clearState() {
recordIsStopped = false
isRecording = false
recordIsPaused = false
isPlaying = false
durationSeconds = 0L
position = 0L
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun AudioRecorder( fun AudioRecorder(
@ -49,13 +80,7 @@ fun AudioRecorder(
onStopRecording: () -> Unit, onStopRecording: () -> Unit,
onPauseRecording: () -> Unit, onPauseRecording: () -> Unit,
onStartRecording: (fromPaused: Boolean) -> Unit, onStartRecording: (fromPaused: Boolean) -> Unit,
isRecording: Boolean, state: AudioRecorderState = AudioRecorderState(),
recordIsStopped: Boolean,
recordIsPaused: Boolean,
isPlaying: Boolean,
isLoading: Boolean,
durationSeconds: Long,
position: Long,
onSeek: (position: Float) -> Unit = {}, onSeek: (position: Float) -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -68,9 +93,9 @@ fun AudioRecorder(
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
LaunchedEffect(isRecording, recordIsPaused) { LaunchedEffect(state.isRecording, state.recordIsPaused) {
coroutineScope { coroutineScope {
while(isRecording && !recordIsPaused) { while(state.isRecording && !state.recordIsPaused) {
duration += 500 duration += 500
delay(500.milliseconds) delay(500.milliseconds)
} }
@ -94,7 +119,7 @@ fun AudioRecorder(
when { when {
audioRecordPermissionState.status.isGranted -> { audioRecordPermissionState.status.isGranted -> {
when { when {
!isRecording && !recordIsStopped-> { !state.isRecording && !state.recordIsStopped-> {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -113,7 +138,7 @@ fun AudioRecorder(
} }
} }
isRecording && !recordIsStopped -> { state.isRecording && !state.recordIsStopped -> {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
@ -121,7 +146,7 @@ fun AudioRecorder(
Text(duration.milliseconds.clockFormat(), modifier = Modifier.padding(horizontal = 4.dp)) Text(duration.milliseconds.clockFormat(), modifier = Modifier.padding(horizontal = 4.dp))
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
IconButton(onClick = { IconButton(onClick = {
if(recordIsPaused) { if(state.recordIsPaused) {
onStartRecording(true) onStartRecording(true)
} else { } else {
@ -129,7 +154,7 @@ fun AudioRecorder(
} }
}) { }) {
if(recordIsPaused) { if(state.recordIsPaused) {
Icon(painterResource(R.drawable.baseline_circle_24), null, tint = Color.Red) Icon(painterResource(R.drawable.baseline_circle_24), null, tint = Color.Red)
} else { } else {
Icon(painterResource(R.drawable.baseline_pause_24), null) Icon(painterResource(R.drawable.baseline_pause_24), null)
@ -146,10 +171,10 @@ fun AudioRecorder(
else -> { else -> {
AudioPlayer( AudioPlayer(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
positionMs = position, positionMs = state.position,
isLoading = isLoading, isLoading = state.isLoading,
isPlaying = isPlaying, isPlaying = state.isPlaying,
durationMs = durationSeconds, durationMs = state.durationSeconds,
onPlay = onPlay, onPlay = onPlay,
onPause = onPause, onPause = onPause,
onSeek = onSeek onSeek = onSeek

View File

@ -0,0 +1,10 @@
package com.isolaatti.audio.player
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
)

View File

@ -15,6 +15,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -33,10 +34,14 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioPlayer
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.AudioPlaybackState
import com.isolaatti.images.common.domain.entity.RemoteImage import com.isolaatti.images.common.domain.entity.RemoteImage
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
import com.isolaatti.utils.UrlGen.userProfileImage import com.isolaatti.utils.UrlGen.userProfileImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PostComponent( fun PostComponent(
post: Post, post: Post,
@ -50,6 +55,7 @@ fun PostComponent(
onUsernameClick: () -> Unit = {}, onUsernameClick: () -> Unit = {},
onImageClick: (images: List<RemoteImage>, index: Int) -> Unit = {_, _ -> }, onImageClick: (images: List<RemoteImage>, index: Int) -> Unit = {_, _ -> },
onHashtagClick: (hashtag: String) -> Unit = {}, onHashtagClick: (hashtag: String) -> Unit = {},
onPlay: (audio: Audio, positionMs: Long) -> Unit = {_, _ -> },
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Card(modifier = modifier.padding(8.dp).clickable { onClick() }) { Card(modifier = modifier.padding(8.dp).clickable { onClick() }) {
@ -77,7 +83,22 @@ fun PostComponent(
} }
post.audio?.let { post.audio?.let {
// TODO audio player here AudioPlayer(
modifier = Modifier.fillMaxWidth().height(80.dp),
positionMs = 2000L,
isPlaying = false,
durationMs = 30_000L,
isLoading = false,
onPlay = {
},
onPause = {
},
onSeek = {
}
)
} }
if(post.textContent.isNotBlank()) { if(post.textContent.isNotBlank()) {
@ -167,5 +188,5 @@ fun PostComponent(
@Composable @Composable
@Preview(device = Devices.PIXEL_5) @Preview(device = Devices.PIXEL_5)
fun PostPreview() { fun PostPreview() {
PostComponent(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 1, numberOfComments = 2)) PostComponent(Post(id = 0L, textContent = "Test", userId = 1, privacy = 2, date = "Date", images = emptyList(), liked = false, userName = "Username", numberOfLikes = 2, numberOfComments = 3))
} }

View File

@ -4,6 +4,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
@ -35,6 +36,10 @@ class Post(
var numberOfLikes by mutableIntStateOf(numberOfLikes) var numberOfLikes by mutableIntStateOf(numberOfLikes)
var numberOfComments by mutableStateOf(numberOfComments) var numberOfComments by mutableStateOf(numberOfComments)
var playing by mutableStateOf(false)
var durationMs by mutableLongStateOf(0L)
var progressMs by mutableLongStateOf(0L)
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readLong(), parcel.readLong(),
parcel.readString()!!, parcel.readString()!!,

View File

@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.components.AudioRecorderState
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.images.common.domain.entity.Image import com.isolaatti.images.common.domain.entity.Image
@ -61,19 +62,9 @@ class CreatePostViewModel @Inject constructor(
private var audioDraft: Long? = null private var audioDraft: Long? = null
private var audioId: String? = null private var audioId: String? = null
// region Player state val audioRecorderState = AudioRecorderState()
val isPlaying: MutableStateFlow<Boolean> = MutableStateFlow(false)
var position: MutableStateFlow<Long> = MutableStateFlow(0L)
var duration: MutableStateFlow<Long> = MutableStateFlow(0L)
var isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
var ended: Boolean = false var ended: Boolean = false
// endregion
// region Recorder state
var isRecording: MutableStateFlow<Boolean> = MutableStateFlow(false)
var recordingIsPaused: MutableStateFlow<Boolean> = MutableStateFlow(false)
var recodingIsStopped: MutableStateFlow<Boolean> = MutableStateFlow(false)
// endregion
/** /**

View File

@ -4,6 +4,9 @@ import android.util.Log
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.components.AudioRecorderState
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.AudioPlaybackState
import com.isolaatti.posting.likes.domain.repository.LikesRepository import com.isolaatti.posting.likes.domain.repository.LikesRepository
import com.isolaatti.posting.posts.data.remote.FeedDto import com.isolaatti.posting.posts.data.remote.FeedDto
import com.isolaatti.posting.posts.domain.entity.Post import com.isolaatti.posting.posts.domain.entity.Post
@ -40,8 +43,14 @@ abstract class PostListingViewModelBase : ViewModel() {
val errorLoading: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData() val errorLoading: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData()
var isLoadingFromScrolling = false var isLoadingFromScrolling = false
val mediaControllerIsAvailable: MutableStateFlow<Boolean> = MutableStateFlow(false)
val audioState: MutableStateFlow<AudioPlaybackState> = MutableStateFlow(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()
abstract fun getFeed(refresh: Boolean, hashtag: String?) abstract fun getFeed(refresh: Boolean, hashtag: String?)
@ -115,4 +124,16 @@ 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

@ -124,21 +124,21 @@ class CreatePostActivity : IsolaattiBaseActivity() {
private val playerListener: Player.Listener = object: Player.Listener { private val playerListener: Player.Listener = object: Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
viewModel.isPlaying.value = isPlaying viewModel.audioRecorderState.isPlaying = isPlaying
} }
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
when(playbackState) { when(playbackState) {
Player.STATE_READY -> { Player.STATE_READY -> {
viewModel.ended = false viewModel.ended = false
viewModel.isLoading.value = false viewModel.audioRecorderState.isLoading = false
viewModel.duration.value = this@CreatePostActivity.mediaController.duration viewModel.audioRecorderState.durationSeconds = this@CreatePostActivity.mediaController.duration
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
viewModel.ended = true viewModel.ended = true
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
viewModel.isLoading.value = true viewModel.audioRecorderState.isLoading = true
} }
Player.STATE_IDLE -> {} Player.STATE_IDLE -> {}
@ -291,19 +291,11 @@ class CreatePostActivity : IsolaattiBaseActivity() {
AnimatedVisibility(audioRecorderIsVisible) { AnimatedVisibility(audioRecorderIsVisible) {
val isPlaying by viewModel.isPlaying.collectAsState()
val position by viewModel.position.collectAsState()
val duration by viewModel.duration.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isRecording by viewModel.isRecording.collectAsState() LaunchedEffect(viewModel.audioRecorderState.isPlaying) {
val recordIsPaused by viewModel.recordingIsPaused.collectAsState() while(viewModel.audioRecorderState.isPlaying) {
val recordIsStopped by viewModel.recodingIsStopped.collectAsState()
LaunchedEffect(isPlaying) {
while(isPlaying) {
delay(500.milliseconds) delay(500.milliseconds)
viewModel.position.value = mediaController.currentPosition viewModel.audioRecorderState.position = mediaController.currentPosition
} }
} }
@ -327,12 +319,7 @@ class CreatePostActivity : IsolaattiBaseActivity() {
mediaRecorder.release() mediaRecorder.release()
// clear references // clear references
viewModel.recodingIsStopped.value = false viewModel.audioRecorderState.clearState()
viewModel.isRecording.value = false
viewModel.recordingIsPaused.value = false
viewModel.isPlaying.value = false
viewModel.duration.value = 0L
viewModel.position.value = 0L
viewModel.audioToUpload = null viewModel.audioToUpload = null
}, },
onPlay = { onPlay = {
@ -367,18 +354,19 @@ class CreatePostActivity : IsolaattiBaseActivity() {
start() start()
} }
} }
viewModel.recodingIsStopped.value = false
viewModel.isRecording.value = true viewModel.audioRecorderState.recordIsStopped = false
viewModel.recordingIsPaused.value = false viewModel.audioRecorderState.isRecording = true
viewModel.audioRecorderState.recordIsPaused = false
}, },
onPauseRecording = { onPauseRecording = {
mediaRecorder.pause() mediaRecorder.pause()
viewModel.recodingIsStopped.value = false viewModel.audioRecorderState.recordIsStopped = false
viewModel.isRecording.value = true viewModel.audioRecorderState.isRecording = true
viewModel.recordingIsPaused.value = true viewModel.audioRecorderState.recordIsPaused = true
}, },
onStopRecording = { onStopRecording = {
mediaRecorder.stop() mediaRecorder.stop()
@ -392,17 +380,11 @@ class CreatePostActivity : IsolaattiBaseActivity() {
.build() .build()
) )
viewModel.audioToUpload = audioUri viewModel.audioToUpload = audioUri
viewModel.recodingIsStopped.value = true viewModel.audioRecorderState.recordIsStopped = true
viewModel.isRecording.value = false viewModel.audioRecorderState.isRecording = false
viewModel.recordingIsPaused.value = false viewModel.audioRecorderState.recordIsPaused = false
}, },
isPlaying = isPlaying, state = viewModel.audioRecorderState,
position = position,
durationSeconds = duration,
isRecording = isRecording,
recordIsPaused = recordIsPaused,
recordIsStopped = recordIsStopped,
isLoading = isLoading,
onSeek = { ms -> onSeek = { ms ->
mediaController.seekTo(ms.toLong()) mediaController.seekTo(ms.toLong())
} }

View File

@ -26,6 +26,7 @@ import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.isolaatti.BuildConfig import com.isolaatti.BuildConfig
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioRecorderState
import com.isolaatti.audio.common.domain.Audio import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.common.ErrorMessageViewModel
@ -63,6 +64,7 @@ class PostListingFragment : Fragment() {
private val viewModel: PostListingViewModel by viewModels() private val viewModel: PostListingViewModel by viewModels()
val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels() val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.d(LOG_TAG, "onCreate ; hashtag: $hashtag") Log.d(LOG_TAG, "onCreate ; hashtag: $hashtag")

View File

@ -45,6 +45,8 @@ class ProfileViewModel @Inject constructor(
val isRefreshing: MutableStateFlow<Boolean> = MutableStateFlow(false) val isRefreshing: MutableStateFlow<Boolean> = MutableStateFlow(false)
val settingUpAudio: MutableStateFlow<Boolean> = 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

View File

@ -2,6 +2,8 @@ package com.isolaatti.profile.ui
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.media.MediaRecorder
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
@ -11,20 +13,25 @@ import android.widget.Toast
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.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
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.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.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.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.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -35,6 +42,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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
@ -58,6 +66,9 @@ 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
import com.isolaatti.R import com.isolaatti.R
import com.isolaatti.audio.common.components.AudioPlayer
import com.isolaatti.audio.common.components.AudioRecorder
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.player.MediaService import com.isolaatti.audio.player.MediaService
import com.isolaatti.common.Dialogs import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel import com.isolaatti.common.ErrorMessageViewModel
@ -81,8 +92,12 @@ import com.isolaatti.profile.ui.components.ProfileHeader
import com.isolaatti.reports.data.ContentType 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.flow.MutableStateFlow
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@AndroidEntryPoint @AndroidEntryPoint
class ProfileMainFragment : Fragment() { class ProfileMainFragment : Fragment() {
@ -120,9 +135,12 @@ class ProfileMainFragment : Fragment() {
private lateinit var mediaControllerFuture: ListenableFuture<MediaController> private lateinit var mediaControllerFuture: ListenableFuture<MediaController>
private lateinit var mediaController: MediaController private lateinit var mediaController: MediaController
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
val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), MediaService::class.java)) val sessionToken = SessionToken(requireContext(), ComponentName(requireContext(), MediaService::class.java))
mediaControllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync() mediaControllerFuture = MediaController.Builder(requireContext(), sessionToken).buildAsync()
@ -130,6 +148,58 @@ class ProfileMainFragment : Fragment() {
getData() getData()
} }
private lateinit var recordOutputFile: File
// 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() {
}
private fun seekTo(ms: Float) {
}
private fun playAudio(descriptionAudio: Audio) {
}
private fun pauseAudio(descriptionAudio: Audio) {
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView( override fun onCreateView(
@ -146,6 +216,14 @@ class ProfileMainFragment : Fragment() {
val lazyColumState = rememberLazyListState() val lazyColumState = rememberLazyListState()
val profile by viewModel.profile.observeAsState() val profile by viewModel.profile.observeAsState()
val followingState by viewModel.followingState.observeAsState() 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()
val isAtTop by remember { val isAtTop by remember {
derivedStateOf { derivedStateOf {
lazyColumState.firstVisibleItemIndex == 0 && lazyColumState.firstVisibleItemScrollOffset == 0 lazyColumState.firstVisibleItemIndex == 0 && lazyColumState.firstVisibleItemScrollOffset == 0
@ -196,10 +274,8 @@ class ProfileMainFragment : Fragment() {
} }
val posts by viewModel.posts.collectAsState()
val loadingProfile by viewModel.loadingProfile.collectAsState()
val playingAudio by remember { mutableStateOf(true) }
IsolaattiTheme { IsolaattiTheme {
Scaffold( Scaffold(
topBar = { topBar = {
@ -286,8 +362,77 @@ class ProfileMainFragment : Fragment() {
onFollowChange = { follow -> onFollowChange = { follow ->
viewModel.followUser() viewModel.followUser()
}, },
profileAudioProgress = 0.4f, audioPlayer = {
showProfileAudioProgress = true, 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(
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(
onClick = {
viewModel.settingUpAudio.value = true
}
) {
Text("Record audio")
}
}
}
}
}
}
},
onEditProfileClick = { onEditProfileClick = {
editProfile.launch(it) editProfile.launch(it)
} }
@ -355,11 +500,26 @@ class ProfileMainFragment : Fragment() {
} }
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
mediaController = mediaControllerFuture.await() mediaController = mediaControllerFuture.await()
viewModel.mediaControllerIsAvailable.value = true
// check audios directory
directory = File(requireContext().filesDir, "recordings")
withContext(Dispatchers.IO) {
if(!directory.isDirectory) {
if(directory.isFile) {
directory.delete()
}
directory.mkdir()
}
}
} }
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {

View File

@ -65,11 +65,10 @@ fun ProfileHeader(
followerCount: Int, followerCount: Int,
followingCount: Int, followingCount: Int,
onImageClick: () -> Unit, onImageClick: () -> Unit,
showProfileAudioProgress: Boolean,
profileAudioProgress: Float,
onFollowersClick: () -> Unit, onFollowersClick: () -> Unit,
onEditProfileClick: () -> Unit, onEditProfileClick: () -> Unit,
onFollowChange: (Boolean) -> Unit, onFollowChange: (Boolean) -> Unit,
audioPlayer: @Composable () -> Unit = {},
loading: Boolean = false, loading: Boolean = false,
isOwnUser: Boolean, isOwnUser: Boolean,
followingThisUser: Boolean, followingThisUser: Boolean,
@ -121,6 +120,8 @@ fun ProfileHeader(
Text(it, modifier = Modifier.padding(top = 8.dp)) Text(it, modifier = Modifier.padding(top = 8.dp))
} }
audioPlayer()
TextButton(onClick = onFollowersClick, modifier = Modifier.padding(top = 4.dp)) { TextButton(onClick = onFollowersClick, modifier = Modifier.padding(top = 4.dp)) {
Text(stringResource(R.string.go_to_followers_btn_text, followerCount, followingCount)) Text(stringResource(R.string.go_to_followers_btn_text, followerCount, followingCount))
} }
@ -164,8 +165,6 @@ fun ProfileHeaderPreview() {
isOwnUser = true, isOwnUser = true,
followingThisUser = true, followingThisUser = true,
onImageClick = {}, onImageClick = {},
profileAudioProgress = 0.6f,
showProfileAudioProgress = true,
onFollowersClick = {}, onFollowersClick = {},
onFollowChange = {}, onFollowChange = {},
onEditProfileClick = {} onEditProfileClick = {}

View File

@ -230,6 +230,7 @@
<string name="profile">Profile</string> <string name="profile">Profile</string>
<string name="add_post_button_desc">Add post button</string> <string name="add_post_button_desc">Add post button</string>
<string name="you_will_see_what_user_posts_here">When %s posts something you will see it here</string> <string name="you_will_see_what_user_posts_here">When %s posts something you will see it here</string>
<string name="setup_your_profile_audio">Setup your profile 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>

View File

@ -14,8 +14,8 @@ buildscript {
} }
} }
plugins { plugins {
id 'com.android.application' version '8.7.3' apply false id 'com.android.application' version '8.8.2' apply false
id 'com.android.library' version '8.7.3' apply false id 'com.android.library' version '8.8.2' apply false
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
id 'com.google.dagger.hilt.android' version '2.53.1' apply false id 'com.google.dagger.hilt.android' version '2.53.1' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false id 'com.google.gms.google-services' version '4.4.2' apply false

View File

@ -1,6 +1,6 @@
#Thu Jul 27 21:16:21 CST 2023 #Thu Jul 27 21:16:21 CST 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists