From e1cba1c3f3dbc086b40d57b8c4cae0ec0c9f0c43 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 6 Dec 2025 23:44:09 +0530 Subject: [PATCH 1/9] Updated project dependencies SharedElementModifiers.kt parameters naming was changed in library --- .../java/com/eva/recorderapp/RecorderApp.kt | 30 +++++++++++++++++-- .../ui/animation/SharedElementModifiers.kt | 12 ++++---- gradle/libs.versions.toml | 16 +++++----- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/eva/recorderapp/RecorderApp.kt b/app/src/main/java/com/eva/recorderapp/RecorderApp.kt index 44610c00..a960ba4e 100644 --- a/app/src/main/java/com/eva/recorderapp/RecorderApp.kt +++ b/app/src/main/java/com/eva/recorderapp/RecorderApp.kt @@ -3,8 +3,10 @@ package com.eva.recorderapp import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager +import android.os.StrictMode import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.tooling.ComposeStackTraceMode import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.hilt.work.HiltWorkerFactory @@ -37,8 +39,8 @@ class RecorderApp : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() - // enabled compose stack-trace - Composer.setDiagnosticStackTraceEnabled(enabled = BuildConfig.DEBUG) + enableComposeStackTrace() + enableStrictMode() createNotificationChannels() @@ -86,4 +88,28 @@ class RecorderApp : Application(), Configuration.Provider { notificationManager?.createNotificationChannels(channels) } + + + private fun enableComposeStackTrace() { + if (!BuildConfig.DEBUG) return + Composer.setDiagnosticStackTraceMode(ComposeStackTraceMode.SourceInformation) + } + + private fun enableStrictMode() { + if (!BuildConfig.DEBUG) return + // thread policy + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() // Detect all thread violations + .penaltyLog() // Log to console + .build() + ) + + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() // Detect all VM violations + .penaltyLog() + .build() + ) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt b/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt index 66965050..36da491a 100644 --- a/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt +++ b/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt @@ -7,8 +7,6 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.SharedTransitionScope.ResizeMode -import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.ScaleToBounds import androidx.compose.animation.core.Spring.StiffnessMediumLow import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring @@ -32,7 +30,7 @@ fun Modifier.sharedElementWrapper( key: Any, renderInOverlayDuringTransition: Boolean = true, zIndexInOverlay: Float = 0f, - placeHolderSize: SharedTransitionScope.PlaceHolderSize = SharedTransitionScope.PlaceHolderSize.contentSize, + placeHolderSize: SharedTransitionScope.PlaceholderSize = SharedTransitionScope.PlaceholderSize.ContentSize, boundsTransform: BoundsTransform = BoundsTransform { _, _ -> NormalSpring }, ) = composed { val transitionScope = LocalSharedTransitionScopeProvider.current ?: return@composed Modifier @@ -47,7 +45,7 @@ fun Modifier.sharedElementWrapper( animatedVisibilityScope = visibilityScope, renderInOverlayDuringTransition = renderInOverlayDuringTransition, zIndexInOverlay = zIndexInOverlay, - placeHolderSize = placeHolderSize, + placeholderSize = placeHolderSize, boundsTransform = boundsTransform ) } @@ -58,9 +56,9 @@ fun Modifier.sharedBoundsWrapper( enter: EnterTransition = fadeIn(), exit: ExitTransition = fadeOut(), renderInOverlayDuringTransition: Boolean = true, - resizeMode: ResizeMode = ScaleToBounds(ContentScale.FillWidth, Center), + resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.FillWidth, Center), zIndexInOverlay: Float = 0f, - placeHolderSize: SharedTransitionScope.PlaceHolderSize = SharedTransitionScope.PlaceHolderSize.contentSize, + placeHolderSize: SharedTransitionScope.PlaceholderSize = SharedTransitionScope.PlaceholderSize.ContentSize, boundsTransform: BoundsTransform = BoundsTransform { _, _ -> NormalSpring }, ) = composed { @@ -79,7 +77,7 @@ fun Modifier.sharedBoundsWrapper( boundsTransform = boundsTransform, renderInOverlayDuringTransition = renderInOverlayDuringTransition, zIndexInOverlay = zIndexInOverlay, - placeHolderSize = placeHolderSize, + placeholderSize = placeHolderSize, resizeMode = resizeMode, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e31ed25..b0e36a6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -agp = "8.13.0" +agp = "8.13.1" concurrentFuturesKtx = "1.3.0" -datastore = "1.1.7" +datastore = "1.2.0" coreSplashscreen = "1.2.0" glance = "1.1.1" graphicsShapes = "1.1.0" @@ -16,19 +16,19 @@ espressoCore = "3.7.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxDatetime = "0.7.1" kotlinxSerializationJson = "1.9.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2025.11.00" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.1" +composeBom = "2025.12.00" ksp = "2.3.0" hilt = "2.57.2" media3Common = "1.8.0" navigationCompose = "2.9.6" playServicesLocationVersion = "21.3.0" -roomCompiler = "2.8.3" -uiTextGoogleFonts = "1.9.4" +roomCompiler = "2.8.4" +uiTextGoogleFonts = "1.10.0" workRuntimeKtxVersion = "2.11.0" hiltWork = "1.3.0" -protobufJavalite = "4.33.0" +protobufJavalite = "4.33.1" protobuf_version = "0.9.5" protobuf_gen_java_lite = "3.0.0" materialIconExtended = "1.7.8" From 91737eab0cb7fcbf4a37db5ffede7444d24c2fdc Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 7 Dec 2025 19:24:25 +0530 Subject: [PATCH 2/9] Correcting the setup for mediacontroller Rather than setting up the media session on MediaPlayerService.kt configuring it on AudioPlayerMediaCallBacks.kt In AudioPlayerViewModel.kt we cancel the ongoing controller setup if ControllerEvents.OnRemoveController is called so controller is not being created if ongoing MediaControllerProvider.kt filterNotNull is causing the Ui to enter ANR Using MediaControllerState.kt to control the setup and cleanup PendingIntentUtils.kt is now based on context rather than service --- .../eva/player/data/MediaPlayerConstants.kt | 5 + .../data/player/MediaControllerProvider.kt | 100 +++++++++++------- .../data/player/MediaControllerState.kt | 9 ++ .../data/service/AudioPlayerMediaCallBacks.kt | 28 ++++- .../player/data/service/MediaPlayerService.kt | 51 +++------ .../player/data/service/PendingIntentUtils.kt | 37 +++---- .../com/eva/player/di/PlayerServiceModule.kt | 2 +- .../viewmodel/AudioPlayerViewModel.kt | 13 ++- 8 files changed, 140 insertions(+), 105 deletions(-) create mode 100644 data/player/src/main/java/com/eva/player/data/MediaPlayerConstants.kt create mode 100644 data/player/src/main/java/com/eva/player/data/player/MediaControllerState.kt diff --git a/data/player/src/main/java/com/eva/player/data/MediaPlayerConstants.kt b/data/player/src/main/java/com/eva/player/data/MediaPlayerConstants.kt new file mode 100644 index 00000000..90cf7228 --- /dev/null +++ b/data/player/src/main/java/com/eva/player/data/MediaPlayerConstants.kt @@ -0,0 +1,5 @@ +package com.eva.player.data + +internal object MediaPlayerConstants { + const val PLAYER_AUDIO_FILE_ID_KEY = "PLAYER_AUDIO_ID" +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt b/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt index 2d23d655..fb1ed749 100644 --- a/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt +++ b/data/player/src/main/java/com/eva/player/data/player/MediaControllerProvider.kt @@ -9,6 +9,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.session.SessionError import androidx.media3.session.SessionToken +import com.eva.player.data.MediaPlayerConstants import com.eva.player.data.service.MediaPlayerService import com.eva.player.domain.AudioFilePlayer import com.eva.player.domain.model.PlayerMetaData @@ -16,15 +17,15 @@ import com.eva.player.domain.model.PlayerPlayBackSpeed import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel import com.eva.utils.tryWithLock +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlin.time.Duration @@ -32,46 +33,57 @@ import kotlin.time.Duration private const val TAG = "PLAYED_MEDIA_CONTROLLER" @OptIn(ExperimentalCoroutinesApi::class) +@androidx.annotation.OptIn(UnstableApi::class) internal class MediaControllerProvider(private val context: Context) : AudioFilePlayer { @Volatile private var _controller: MediaController? = null private var _lock = Mutex() - private val _playerFlow = MutableStateFlow(null) - private val _isConnected = MutableStateFlow(false) - - private val _currentPlayer: Flow = - combine(_isConnected, _playerFlow) { connected, player -> - if (connected && player != null) player else null - }.filterNotNull() + private val _playerState: MutableStateFlow = + MutableStateFlow(MediaControllerState.Disconnected) private val player: AudioFilePlayer? - get() = _playerFlow.value + get() = (_playerState.value as? MediaControllerState.Connected)?.player override val trackInfoAsFlow: Flow - get() = _currentPlayer.flatMapLatest { player -> player.trackInfoAsFlow } + get() = _playerState.flatMapLatest { state -> + when (state) { + is MediaControllerState.Connected -> state.player.trackInfoAsFlow + else -> emptyFlow() + } + } override val playerMetaDataFlow: Flow - get() = _currentPlayer.flatMapLatest { player -> player.playerMetaDataFlow } + get() = _playerState.flatMapLatest { state -> + when (state) { + is MediaControllerState.Connected -> state.player.playerMetaDataFlow + else -> emptyFlow() + } + } override val isPlaying: Flow - get() = _currentPlayer.flatMapLatest { player -> player.isPlaying } + get() = _playerState.flatMapLatest { state -> + when (state) { + is MediaControllerState.Connected -> state.player.isPlaying + else -> flowOf(false) + } + } override val isControllerReady: Flow - get() = _isConnected + get() = _playerState.map { state -> state is MediaControllerState.Connected } - @androidx.annotation.OptIn(UnstableApi::class) private val _controllerListener = object : MediaController.Listener { override fun onDisconnected(controller: MediaController) { super.onDisconnected(controller) Log.i(TAG, "MEDIA CONTROLLER DISCONNECTED") - // update is connected - _isConnected.update { false } - // clear the player - val oldInstance = _playerFlow.getAndUpdate { null } - oldInstance?.cleanUp() + // clear the player if its connected state + val oldInstance = _playerState.value + if (oldInstance is MediaControllerState.Connected) + oldInstance.player.cleanUp() + // then disconnect the controller + _playerState.value = MediaControllerState.Disconnected } override fun onError(controller: MediaController, sessionError: SessionError) { @@ -81,20 +93,20 @@ internal class MediaControllerProvider(private val context: Context) : AudioFile } override suspend fun prepareController(audioId: Long) { - - val sessionExtras = bundleOf(MediaPlayerService.PLAYER_AUDIO_FILE_ID_KEY to audioId) + if (_controller != null) { + Log.d(TAG, "CONTROLLER IS ALREADY SET ") + return + } + val sessionExtras = bundleOf(MediaPlayerConstants.PLAYER_AUDIO_FILE_ID_KEY to audioId) val sessionToken = SessionToken( context, ComponentName(context, MediaPlayerService::class.java) ) _lock.tryWithLock(this) { - if (_controller != null) { - Log.d(TAG, "CONTROLLER IS ALREADY SET ") - return - } try { - Log.d(TAG, "PREPARING THE PLAYER") + _playerState.value = MediaControllerState.Connecting + Log.d(TAG, "PREPARING THE CONTROLLER") // prepare the controller future withContext(Dispatchers.Main.immediate) { MediaController.Builder(context, sessionToken) @@ -106,11 +118,11 @@ internal class MediaControllerProvider(private val context: Context) : AudioFile } Log.i(TAG, "CONTROLLER CREATED") // set the player instance - _isConnected.update { _controller?.isConnected ?: false } - _playerFlow.value = _controller?.let { player -> AudioFilePlayerImpl(player) } + val player = AudioFilePlayerImpl(_controller!!) + _playerState.value = MediaControllerState.Connected(player) } catch (e: Exception) { Log.e(TAG, "FAILED TO RESOLVE FUTURE", e) - e.printStackTrace() + if (e !is CancellationException) e.printStackTrace() } } } @@ -125,13 +137,23 @@ internal class MediaControllerProvider(private val context: Context) : AudioFile } override fun cleanUp() { - Log.d(TAG, "CLEARING UP CONTROLLER") - // release the controller if not released - _controller?.release() - _controller = null - // perform player cleanup - val oldInstance = _playerFlow.getAndUpdate { null } - oldInstance?.cleanUp() + try { + if (_controller == null) { + Log.d(TAG, "CONTROLLER ALREADY RELEASED") + return + } + // perform player cleanup + val oldInstance = _playerState.value + if (oldInstance is MediaControllerState.Connected) + oldInstance.player.cleanUp() + // release the controller if not released + _controller?.release() + + } finally { + Log.d(TAG, "CONTROLLER CLEANED") + _controller = null + _playerState.value = MediaControllerState.Disconnected + } } override fun onMuteDevice() { diff --git a/data/player/src/main/java/com/eva/player/data/player/MediaControllerState.kt b/data/player/src/main/java/com/eva/player/data/player/MediaControllerState.kt new file mode 100644 index 00000000..b2c6194b --- /dev/null +++ b/data/player/src/main/java/com/eva/player/data/player/MediaControllerState.kt @@ -0,0 +1,9 @@ +package com.eva.player.data.player + +import com.eva.player.domain.AudioFilePlayer + +internal sealed interface MediaControllerState { + data object Disconnected : MediaControllerState + data object Connecting : MediaControllerState + data class Connected(val player: AudioFilePlayer) : MediaControllerState +} \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/service/AudioPlayerMediaCallBacks.kt b/data/player/src/main/java/com/eva/player/data/service/AudioPlayerMediaCallBacks.kt index 7d53532f..97cf1d70 100644 --- a/data/player/src/main/java/com/eva/player/data/service/AudioPlayerMediaCallBacks.kt +++ b/data/player/src/main/java/com/eva/player/data/service/AudioPlayerMediaCallBacks.kt @@ -1,6 +1,8 @@ package com.eva.player.data.service +import android.content.Context import android.os.Bundle +import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi @@ -8,12 +10,15 @@ import androidx.media3.session.CommandButton import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionResult +import com.eva.player.data.MediaPlayerConstants import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +private const val TAG = "PLAYER_MEDIA_CALLBACK" + @OptIn(UnstableApi::class) -internal class AudioPlayerMediaCallBacks : MediaSession.Callback { +internal class AudioPlayerMediaCallBacks(private val context: Context) : MediaSession.Callback { override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo) : MediaSession.ConnectionResult { @@ -39,9 +44,23 @@ internal class AudioPlayerMediaCallBacks : MediaSession.Callback { .setAvailableSessionCommands(sessionCommands) .build() + val audioId = controller.connectionHints + .getLong(MediaPlayerConstants.PLAYER_AUDIO_FILE_ID_KEY, -1) + + Log.d(TAG, "MEDIA CALLBACK CONNECTED") + // set activity + if (audioId != -1L) { + val pendingIntent = context.createPlayerIntent(audioId) + session.setSessionActivity(pendingIntent) + Log.d(TAG, "PLAYER SESSION ACTIVITY SET") + } return result } + override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) { + Log.d(TAG, "MEDIA CALLBACK DISCONNECT") + } + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) { val layout = ImmutableList.of( PlayerSessionCommands.rewindButton, @@ -62,12 +81,15 @@ internal class AudioPlayerMediaCallBacks : MediaSession.Callback { when (customCommand.customAction) { PlayerSessionCommands.FORWARD_BY_1SEC -> { val newPos = session.player.currentPosition + 1000 - session.player.seekTo(newPos) + val finalSeekPos = if (newPos <= session.player.duration) + newPos else session.player.duration + session.player.seekTo(finalSeekPos) } PlayerSessionCommands.REWIND_BY_1SEC -> { val newPos = session.player.currentPosition - 1000 - session.player.seekTo(newPos) + val finalSeekPos = if (newPos >= 0L) newPos else 0L + session.player.seekTo(finalSeekPos) } } return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) diff --git a/data/player/src/main/java/com/eva/player/data/service/MediaPlayerService.kt b/data/player/src/main/java/com/eva/player/data/service/MediaPlayerService.kt index f6d0e31b..8d6c9163 100644 --- a/data/player/src/main/java/com/eva/player/data/service/MediaPlayerService.kt +++ b/data/player/src/main/java/com/eva/player/data/service/MediaPlayerService.kt @@ -16,8 +16,6 @@ private const val TAG = "PLAYER_SERVICE" @AndroidEntryPoint class MediaPlayerService : MediaSessionService() { - private var audioId: Long = -1 - @Inject lateinit var mediaSession: MediaSession @@ -27,64 +25,41 @@ class MediaPlayerService : MediaSessionService() { private val listener = object : Listener { override fun onForegroundServiceStartNotAllowedException() { Log.e(TAG, "CANNOT START FOREGROUND SERVICE") + stopSelf() } } override fun onCreate() { super.onCreate() - setMediaNotificationProvider(notification) - Log.d(TAG, "MEDIA SESSION CONFIGURED AND NOTIFICATION SET") + Log.d(TAG, "MEDIA SESSION SERVICE READY") } - override fun onTaskRemoved(rootIntent: Intent?) { - val player = mediaSession.player - if (player.playWhenReady) { - // Make sure the service is not in foreground. - player.pause() - } - stopSelf() + Log.d(TAG, "TASK REMOVED CALLED") + pauseAllPlayersAndStopSelf() } + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession = + mediaSession.apply { setListener(listener) } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession { - Log.i(TAG, "SESSION SET") - - audioId = controllerInfo.connectionHints - .getLong(PLAYER_AUDIO_FILE_ID_KEY, -1) - - return mediaSession.apply { - setListener(listener) - if (audioId != -1L) { - val pendingIntent = createPlayerIntent(audioId) - setSessionActivity(pendingIntent) - } - } - } - - - override fun onDestroy() { - val backStackEntry = createBackStackIntent(audioId) + private fun cleanUp() { + Log.d(TAG, "CLEAN UP IN MEDIA SESSION") mediaSession.apply { - setSessionActivity(backStackEntry) + player.stop() + player.clearMediaItems() // release the player player.release() // release the session release() } - - Log.d(TAG, "REMOVED SESSION LISTENER") clearListener() - audioId = -1 + } + override fun onDestroy() { + cleanUp() Log.d(TAG, "PLAYER SERVICE DESTROYED") super.onDestroy() } - - - companion object { - const val PLAYER_AUDIO_FILE_ID_KEY = "PLAYER_AUDIO_ID" - } } \ No newline at end of file diff --git a/data/player/src/main/java/com/eva/player/data/service/PendingIntentUtils.kt b/data/player/src/main/java/com/eva/player/data/service/PendingIntentUtils.kt index a3753622..5505bf50 100644 --- a/data/player/src/main/java/com/eva/player/data/service/PendingIntentUtils.kt +++ b/data/player/src/main/java/com/eva/player/data/service/PendingIntentUtils.kt @@ -1,49 +1,46 @@ package com.eva.player.data.service import android.app.PendingIntent -import android.app.Service import android.app.TaskStackBuilder +import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.eva.utils.IntentConstants import com.eva.utils.IntentRequestCodes import com.eva.utils.NavDeepLinks -internal fun Service.createBackStackIntent(audioId: Long): PendingIntent { - val activityIntent = Intent().apply { - setClassName(applicationContext, IntentConstants.MAIN_ACTIVITY) - } - - return TaskStackBuilder.create(applicationContext).apply { - // add the recorder - addNextIntent( - activityIntent.apply { +internal fun Context.createBackStackIntent(audioId: Long): PendingIntent { + val stackBuilder = TaskStackBuilder.create(applicationContext) + .addNextIntent( + Intent().apply { + setClassName(applicationContext, IntentConstants.MAIN_ACTIVITY) data = NavDeepLinks.RECORDER_DESTINATION_PATTERN.toUri() action = Intent.ACTION_VIEW } ) - // the recordings - addNextIntent( - activityIntent.apply { + .addNextIntent( + Intent().apply { + setClassName(applicationContext, IntentConstants.MAIN_ACTIVITY) data = NavDeepLinks.RECORDING_DESTINATION_PATTERN.toUri() action = Intent.ACTION_VIEW }, ) - // then audio files - if (audioId != -1L) { - activityIntent.apply { + if (audioId != -1L) { + stackBuilder.addNextIntent( + Intent().apply { + setClassName(applicationContext, IntentConstants.MAIN_ACTIVITY) data = NavDeepLinks.audioPlayerDestinationUri(audioId).toUri() action = Intent.ACTION_VIEW } - } - }.getPendingIntent( + ) + } + return stackBuilder.getPendingIntent( IntentRequestCodes.PLAYER_BACKSTACK_INTENT.code, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - } -internal fun Service.createPlayerIntent(audioId: Long): PendingIntent { +internal fun Context.createPlayerIntent(audioId: Long): PendingIntent { return Intent().apply { setClassName(applicationContext, IntentConstants.MAIN_ACTIVITY) data = NavDeepLinks.audioPlayerDestinationUri(audioId).toUri() diff --git a/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt b/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt index da536229..7446f4d1 100644 --- a/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt +++ b/data/player/src/main/java/com/eva/player/di/PlayerServiceModule.kt @@ -68,7 +68,7 @@ object PlayerServiceModule { @Named("SERVICE_PLAYER") player: Player, ): MediaSession { - val callback = AudioPlayerMediaCallBacks() + val callback = AudioPlayerMediaCallBacks(context) val extras = bundleOf( MediaConstants.EXTRAS_KEY_SLOT_RESERVATION_SEEK_TO_NEXT to true, diff --git a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt index c48fcd7e..d37e9c57 100644 --- a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt +++ b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt @@ -13,6 +13,7 @@ import com.eva.ui.viewmodel.UIEvents import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -71,14 +72,18 @@ internal class AudioPlayerViewModel @AssistedInject constructor( override val uiEvent: SharedFlow get() = _uiEvents.asSharedFlow() + private var _controllerSetUp: Job? = null fun onControllerEvents(event: ControllerEvents) { when (event) { - is ControllerEvents.OnAddController -> viewModelScope.launch { - player.prepareController(event.audioId) + is ControllerEvents.OnAddController -> { + _controllerSetUp = viewModelScope.launch { player.prepareController(event.audioId) } } - ControllerEvents.OnRemoveController -> player.cleanUp() + ControllerEvents.OnRemoveController -> { + _controllerSetUp?.cancel() + player.cleanUp() + } } } @@ -122,7 +127,7 @@ internal class AudioPlayerViewModel @AssistedInject constructor( override fun onCleared() { // cleanup for controller + _controllerSetUp?.cancel() player.cleanUp() - super.onCleared() } } \ No newline at end of file From 32804157a452a072f026827a397cec3632d3ec1f Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 7 Dec 2025 21:54:02 +0530 Subject: [PATCH 3/9] Sequential load for audio and metadata In PlayerFileProviderImpl we first read the file from media store and then after load the metadata content later on done this helps the screen to load relatively faster In PlayerMetadataViewmodel.kt shortcut is changed on every AudioFileModel update we just need the id here so another flow collector with distinct by id is used which sets the shortcut. EvaluateWithReadingTime.kt is a small logging function to check how much time being used to extract metadata In PlayerFileProvider.kt we need to check if the file exists too before returning the content uri Again not capturing the audio file error here and in AudioPlayerViewModel.kt --- data/recordings/build.gradle.kts | 4 ++ .../data/provider/PlayerFileProviderImpl.kt | 62 ++++++++++++++----- .../data/utils/EvaluateWithReadingTime.kt | 16 +++++ .../exceptions/InvalidAudioFileIdException.kt | 3 + .../domain/provider/PlayerFileProvider.kt | 2 +- .../player_shared/PlayerMetadataViewmodel.kt | 21 +++++-- .../PlayerVisualizerViewmodel.kt | 26 +++++--- .../viewmodel/AudioPlayerViewModel.kt | 7 +-- 8 files changed, 104 insertions(+), 37 deletions(-) create mode 100644 data/recordings/src/main/java/com/eva/recordings/data/utils/EvaluateWithReadingTime.kt create mode 100644 data/recordings/src/main/java/com/eva/recordings/domain/exceptions/InvalidAudioFileIdException.kt diff --git a/data/recordings/build.gradle.kts b/data/recordings/build.gradle.kts index 4778edca..38fca74e 100644 --- a/data/recordings/build.gradle.kts +++ b/data/recordings/build.gradle.kts @@ -5,6 +5,10 @@ plugins { android { namespace = "com.eva.recordings" + + buildFeatures { + buildConfig = true + } } dependencies { diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt index cae46a77..9a31a697 100644 --- a/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/PlayerFileProviderImpl.kt @@ -17,9 +17,11 @@ import androidx.core.os.bundleOf import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.location.domain.utils.parseLocationFromString +import com.eva.recordings.BuildConfig +import com.eva.recordings.data.utils.evaluateWithTimeRead import com.eva.recordings.data.wrapper.RecordingsConstants import com.eva.recordings.data.wrapper.RecordingsContentResolverWrapper -import com.eva.recordings.domain.exceptions.InvalidRecordingIdException +import com.eva.recordings.domain.exceptions.InvalidAudioFileIdException import com.eva.recordings.domain.models.AudioFileModel import com.eva.recordings.domain.models.MediaMetaDataInfo import com.eva.recordings.domain.provider.PlayerFileProvider @@ -56,8 +58,20 @@ internal class PlayerFileProviderImpl( MediaStore.Audio.AudioColumns.MIME_TYPE, ) - override fun providesAudioFileUri(audioId: Long): String { - return ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, audioId).toString() + override suspend fun providesAudioFileUri(audioId: Long): Result { + return withContext(Dispatchers.IO) { + try { + val contentURI = + ContentUris.withAppendedId(RecordingsConstants.AUDIO_VOLUME_URI, audioId) + contentResolver.query(contentURI, arrayOf(MediaStore.MediaColumns._ID), null, null) + ?.use { cursor -> + if (cursor.count > 0) Result.success(contentURI.toString()) + else return@withContext Result.failure(InvalidAudioFileIdException()) + } ?: return@withContext Result.failure(InvalidAudioFileIdException()) + } catch (_: Exception) { + Result.failure(InvalidAudioFileIdException()) + } + } } override fun getAudioFileFromIdFlow( @@ -68,14 +82,26 @@ internal class PlayerFileProviderImpl( // send loading trySend(Resource.Loading) - // send the data launch(Dispatchers.IO) { - // evaluate it and send - val first = getAudioFileFromId(id, readMetaData) - first.fold( - onSuccess = { send(Resource.Success(it)) }, - onFailure = { - send(Resource.Error(Exception(it))) + val result = getAudioFileFromId(id, false) + result.fold( + onSuccess = { model -> + // send the data without metadata + send(Resource.Success(model)) + // evaluate metadata + val metaData = evaluateWithTimeRead( + loggingTag = TAG, + readTime = BuildConfig.DEBUG + ) { + if (!readMetaData) return@evaluateWithTimeRead null + extractMediaInfo(model.fileUri.toUri()) + } + val modelWithMetaData = model.copy(metaData = metaData) + // send data with metadata + send(Resource.Success(modelWithMetaData)) + }, + onFailure = { err -> + if (err is Exception) send(Resource.Error(err)) }, ) } @@ -126,13 +152,17 @@ internal class PlayerFileProviderImpl( null )?.use { cur -> val result = evaluateValuesFromCursor(cur) - ?: return@withContext Result.failure(InvalidRecordingIdException()) - val metadata = if (readMetaData) { - Log.d(TAG, "READING METADATA") + ?: return@withContext Result.failure(InvalidAudioFileIdException()) + + val metaData = evaluateWithTimeRead( + loggingTag = TAG, + readTime = BuildConfig.DEBUG + ) { + if (!readMetaData) return@use result extractMediaInfo(result.fileUri.toUri()) - } else null - result.copy(metaData = metadata) - } ?: return@withContext Result.failure(InvalidRecordingIdException()) + } + result.copy(metaData = metaData) + } ?: return@withContext Result.failure(InvalidAudioFileIdException()) } } } diff --git a/data/recordings/src/main/java/com/eva/recordings/data/utils/EvaluateWithReadingTime.kt b/data/recordings/src/main/java/com/eva/recordings/data/utils/EvaluateWithReadingTime.kt new file mode 100644 index 00000000..71cc3a5f --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/data/utils/EvaluateWithReadingTime.kt @@ -0,0 +1,16 @@ +package com.eva.recordings.data.utils + +import android.util.Log +import kotlin.time.measureTimedValue + +internal inline fun evaluateWithTimeRead( + loggingTag: String = "EVALUATION_TIME", + readTime: Boolean = true, + caller: () -> T +): T { + return if (readTime) { + val (result, duration) = measureTimedValue(block = caller) + Log.d(loggingTag, "EVALUATION TOOK :$duration $result") + result + } else caller() +} \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/InvalidAudioFileIdException.kt b/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/InvalidAudioFileIdException.kt new file mode 100644 index 00000000..2e5dfac1 --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/domain/exceptions/InvalidAudioFileIdException.kt @@ -0,0 +1,3 @@ +package com.eva.recordings.domain.exceptions + +class InvalidAudioFileIdException : Exception("Cannot find the associated audio file") \ No newline at end of file diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt b/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt index 5c6104b8..ef5ebba8 100644 --- a/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt +++ b/data/recordings/src/main/java/com/eva/recordings/domain/provider/PlayerFileProvider.kt @@ -8,7 +8,7 @@ typealias ResourcedDetailedRecordingModel = Resource interface PlayerFileProvider { - fun providesAudioFileUri(audioId: Long): String + suspend fun providesAudioFileUri(audioId: Long): Result fun getAudioFileFromIdFlow( id: Long, diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerMetadataViewmodel.kt b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerMetadataViewmodel.kt index 7ca45fd6..f66223a2 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerMetadataViewmodel.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerMetadataViewmodel.kt @@ -19,12 +19,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import javax.inject.Inject @@ -48,7 +49,10 @@ class PlayerMetadataViewmodel @Inject constructor( val loadState = combine(_isAudioLoaded, _currentAudio, transform = ::prepareLoadState) - .onStart { loadAudioFile() } + .onStart { + loadAudioFile() + setShortCut() + } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(8_000), @@ -69,6 +73,14 @@ class PlayerMetadataViewmodel @Inject constructor( } } + private fun setShortCut() { + // set shortcut only when resource is loaded distinct by id ensures not to call it again + // for simple metadata change + _currentAudio.filterNotNull().distinctUntilChangedBy { it.id } + .onEach { model -> shortcutFacade.addLastPlayedShortcut(model.id) } + .launchIn(viewModelScope) + } + private fun loadAudioFile() = fileProviderUseCase.invoke(audioId) .onEach { res -> when (res) { @@ -81,10 +93,7 @@ class PlayerMetadataViewmodel @Inject constructor( is Resource.Success -> { _isAudioLoaded.update { true } - val model = _currentAudio.updateAndGet { res.data } ?: res.data - // set shortcut only when resource is loaded - // this ensures shortcut is only added if the content is properly - shortcutFacade.addLastPlayedShortcut(model.id) + _currentAudio.update { res.data } } } }.launchIn(viewModelScope) diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt index cab8b205..4bd8f361 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt @@ -17,6 +17,7 @@ import com.eva.ui.viewmodel.UIEvents import com.eva.utils.RecorderConstants import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -46,18 +47,17 @@ class PlayerVisualizerViewmodel @Inject constructor( private val _compressedVisualization = MutableStateFlow(floatArrayOf()) private val _clipConfigs = MutableStateFlow(emptyList()) - // basic flag - private var _isVisualizerStarted = false - private val _uiEvents = MutableSharedFlow() override val uiEvent: SharedFlow get() = _uiEvents + private var _prepareVisualsJob: Job? = null + val isVisualsReady = visualizer.visualizerState .map { it != VisualizerState.NOT_STARTED } .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000L), + started = SharingStarted.Eagerly, initialValue = false ) @@ -65,7 +65,7 @@ class PlayerVisualizerViewmodel @Inject constructor( .onStart { prepareVisuals() } .stateIn( scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), + started = SharingStarted.WhileSubscribed(10_000), initialValue = floatArrayOf() ) @@ -85,16 +85,22 @@ class PlayerVisualizerViewmodel @Inject constructor( private fun prepareVisuals() { // if started once don't start again - if (!_isVisualizerStarted) return - _isVisualizerStarted = true + if (_prepareVisualsJob?.isActive == true) return - visualizer.visualizerState.onEach { state -> + _prepareVisualsJob = visualizer.visualizerState.onEach { state -> // only run this if the visualizer not in finished or running state - if (state != VisualizerState.NOT_STARTED) return@onEach + if (state != VisualizerState.NOT_STARTED) { + _prepareVisualsJob?.cancel() + return@onEach + } + + val fileResult = playerFileProvider.providesAudioFileUri(route.audioId) + // no error propagation + val fileURI = fileResult.getOrNull() ?: return@onEach val result = visualizer.prepareVisualization( lifecycleOwner = _lifecycleOwner, - fileUri = playerFileProvider.providesAudioFileUri(route.audioId), + fileUri = fileURI, timePerPointInMs = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE ) result.onFailure { err -> diff --git a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt index d37e9c57..432b96d3 100644 --- a/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt +++ b/feature/player/src/main/java/com/eva/feature_player/viewmodel/AudioPlayerViewModel.kt @@ -119,10 +119,9 @@ internal class AudioPlayerViewModel @AssistedInject constructor( private fun setAudioModel() = viewModelScope.launch { val result = fileProvider.getAudioFileFromId(audioId) - result.fold( - onSuccess = { data -> _currentFile.update { data } }, - onFailure = { error -> _uiEvents.emit(UIEvents.ShowSnackBar(error.message ?: "")) }, - ) + val file = result.getOrNull() ?: return@launch + // error is not captured here + _currentFile.update { file } } override fun onCleared() { From fb45204ff73c0bbadb09145c46fe1eed6fa90588 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 7 Dec 2025 23:33:21 +0530 Subject: [PATCH 4/9] Included navigate back to list in AudioPlayerRoute.kt Updated ContentStateAnimatedContainer.kt the transformation is simplified and also loading screen was not showing included that ic_music_file.xml the content is updated with new illustration Also updated the strings.xml for the new button text --- .../src/main/res/drawable/ic_music_file.xml | 22 ++---- core/ui/src/main/res/values-bn/strings.xml | 1 + core/ui/src/main/res/values-hi/strings.xml | 1 + core/ui/src/main/res/values/strings.xml | 1 + .../eva/feature_editor/AudioEditorScreen.kt | 5 +- .../composables/AudioFileNotFoundBox.kt | 20 +++++- .../ContentStateAnimatedContainer.kt | 70 ++++++++++--------- .../eva/feature_player/AudioPlayerRoute.kt | 6 ++ .../eva/feature_player/AudioPlayerScreen.kt | 8 ++- 9 files changed, 79 insertions(+), 55 deletions(-) diff --git a/core/ui/src/main/res/drawable/ic_music_file.xml b/core/ui/src/main/res/drawable/ic_music_file.xml index e4955d52..c24f570d 100644 --- a/core/ui/src/main/res/drawable/ic_music_file.xml +++ b/core/ui/src/main/res/drawable/ic_music_file.xml @@ -1,19 +1,9 @@ - + android:width="110dp" + android:height="135dp" + android:viewportWidth="110" + android:viewportHeight="135"> - - - - - + android:fillColor="#FF000000" + android:pathData="m51.09,45.16c1.69,0.44 3.41,0.7 5.16,0.78 1.46,0.07 2.92,-0.26 4.22,-0.94 0.48,-0.31 0.77,-0.84 0.78,-1.41v-9.06c-0.01,-0.57 -0.3,-1.1 -0.78,-1.41 -0.47,-0.32 -1.09,-0.32 -1.56,0 -2.19,1.09 -4.38,0.47 -7.03,-0.16s-6.09,-1.72 -9.38,0.16c-0.53,0.26 -0.84,0.82 -0.78,1.41v18.28c-1.79,-1.75 -4.21,-2.7 -6.72,-2.66 -4,0 -7.59,2.42 -9.11,6.11 -1.51,3.7 -0.64,7.95 2.21,10.75 2.85,2.8 7.11,3.61 10.78,2.03 3.68,-1.57 6.03,-5.21 5.97,-9.2v-15.31c1.88,-0.63 3.91,0 6.25,0.63zM35,66.56c-2.72,0 -5.17,-1.64 -6.21,-4.15 -1.04,-2.51 -0.46,-5.4 1.46,-7.32s4.81,-2.5 7.32,-1.46c2.51,1.04 4.15,3.49 4.15,6.21 0.04,1.79 -0.65,3.53 -1.92,4.8s-3,1.96 -4.8,1.92zM44.84,41.41v-5.94c2.09,-0.46 4.26,-0.29 6.25,0.47 2.25,0.81 4.66,1.08 7.03,0.78v5.78c-1.88,0.78 -3.91,0.16 -6.25,-0.47 -1.69,-0.44 -3.41,-0.7 -5.16,-0.78zM86.25,73.44v-25c0.03,-0.5 -0.21,-0.97 -0.63,-1.25 -0.36,-0.26 -0.81,-0.38 -1.25,-0.31l-25,6.25c-0.71,0.2 -1.21,0.83 -1.25,1.56v18.75c-1.36,-1 -3,-1.55 -4.69,-1.56 -4.28,0.08 -7.73,3.53 -7.81,7.81 0,2.07 0.82,4.06 2.29,5.52 1.46,1.46 3.45,2.29 5.52,2.29s4.06,-0.82 5.52,-2.29c1.46,-1.46 2.29,-3.45 2.29,-5.52 0.01,-0.37 -0.04,-0.74 -0.16,-1.09 0.11,-0.13 0.17,-0.3 0.16,-0.47v-14.38l21.88,-5.47v8.91c-1.36,-1 -3,-1.55 -4.69,-1.56 -4.28,0.08 -7.73,3.53 -7.81,7.81 0,2.07 0.82,4.06 2.29,5.52 1.46,1.46 3.45,2.29 5.52,2.29 4.32,0 7.81,-3.5 7.81,-7.81zM53.44,84.38c-2.59,0 -4.69,-2.1 -4.69,-4.69 0.08,-2.55 2.13,-4.61 4.69,-4.69 2.59,0 4.69,2.1 4.69,4.69 0.04,1.26 -0.44,2.47 -1.32,3.36 -0.89,0.89 -2.11,1.37 -3.36,1.32zM61.25,60.47v-4.53l21.88,-5.47v4.53zM73.75,73.44c0.08,-2.55 2.13,-4.61 4.69,-4.69 2.59,0 4.69,2.1 4.69,4.69 0.04,1.26 -0.44,2.47 -1.32,3.36 -0.89,0.89 -2.11,1.37 -3.36,1.32 -2.59,0 -4.69,-2.1 -4.69,-4.69zM88.59,32.5h0.47c0.65,-0.04 1.21,-0.47 1.41,-1.09 0.44,-1.36 1.02,-2.66 1.72,-3.91 0.17,0.47 0.43,0.89 0.78,1.25 0.76,1.38 2.18,2.27 3.75,2.34 1.16,0.07 2.28,-0.46 2.97,-1.41 0.65,-0.85 0.98,-1.9 0.94,-2.97 0,-2.97 -2.03,-6.09 -5.16,-6.09 -0.78,-0.04 -1.55,0.18 -2.19,0.63 -0.32,-1.6 -0.32,-3.25 0,-4.84 0.15,-0.39 0.14,-0.83 -0.04,-1.21 -0.18,-0.38 -0.5,-0.68 -0.9,-0.82 -0.41,-0.14 -0.87,-0.11 -1.26,0.1 -0.39,0.21 -0.67,0.57 -0.77,1 -0.59,2.74 -0.48,5.59 0.31,8.28 -1.32,2.04 -2.37,4.25 -3.13,6.56 -0.15,0.43 -0.12,0.91 0.09,1.32 0.21,0.41 0.57,0.72 1,0.87zM95.47,23.91c1.09,0 2.03,1.56 2.03,2.97 0.01,0.39 -0.1,0.77 -0.31,1.09h-0.47c-0.31,0 -0.78,-0.31 -1.25,-0.94 -0.51,-0.79 -0.93,-1.62 -1.25,-2.5 0.3,-0.39 0.76,-0.63 1.25,-0.63zM26.72,39.06c1.56,-1.72 1.09,-4.06 -0.78,-5.78s-6.09,-2.66 -8.13,-0.16c-0.5,0.57 -0.82,1.28 -0.94,2.03 -1.45,-0.71 -2.69,-1.79 -3.59,-3.13 -0.22,-0.36 -0.58,-0.61 -1,-0.7 -0.41,-0.09 -0.84,-0.01 -1.19,0.23 -0.71,0.41 -0.98,1.29 -0.63,2.03 1.79,2.27 4.09,4.09 6.72,5.31 0.66,2.32 1.66,4.54 2.97,6.56 0.26,0.45 0.73,0.75 1.25,0.78h0.94c0.68,-0.52 0.88,-1.44 0.47,-2.19 -0.75,-1.18 -1.38,-2.44 -1.88,-3.75l1.41,0.31c1.63,0.26 3.28,-0.33 4.38,-1.56zM20,36.72c-0.19,-0.54 -0.07,-1.14 0.31,-1.56 0.63,-0.94 2.5,-0.47 3.59,0.47s0.78,0.94 0.47,1.41l-1.72,0.31zM15.16,92.97c-2.53,0 -4.81,1.52 -5.77,3.86 -0.97,2.34 -0.43,5.02 1.36,6.81 1.79,1.79 4.47,2.32 6.81,1.36 2.34,-0.96 3.86,-3.25 3.86,-5.77 0,-1.66 -0.66,-3.25 -1.83,-4.42s-2.76,-1.83 -4.42,-1.83zM15.16,102.34c-1.27,0 -2.4,-0.76 -2.89,-1.93 -0.48,-1.17 -0.21,-2.51 0.68,-3.41 0.89,-0.89 2.24,-1.16 3.41,-0.68 1.17,0.48 1.93,1.62 1.93,2.89 0,0.83 -0.33,1.63 -0.91,2.21 -0.59,0.59 -1.38,0.91 -2.21,0.91zM96.41,55.47c-2.59,0 -4.69,2.1 -4.69,4.69s2.1,4.69 4.69,4.69 4.69,-2.1 4.69,-4.69 -2.1,-4.69 -4.69,-4.69zM96.41,61.72c-0.43,0.05 -0.86,-0.1 -1.16,-0.4 -0.3,-0.3 -0.45,-0.73 -0.4,-1.16 0,-0.86 0.7,-1.56 1.56,-1.56s1.56,0.7 1.56,1.56c0.05,0.43 -0.1,0.86 -0.4,1.16 -0.3,0.3 -0.73,0.45 -1.16,0.4zM84.22,92.81c-3.13,-4.22 -8.91,-6.09 -9.22,-6.25 -0.37,-0.15 -0.79,-0.13 -1.15,0.05 -0.36,0.18 -0.62,0.5 -0.73,0.89 -0.15,0.39 -0.14,0.83 0.04,1.21 0.18,0.38 0.5,0.68 0.9,0.82 2,0.79 3.89,1.84 5.63,3.13 -2.66,0.31 -5,1.41 -5.63,3.59s0.63,4.38 2.81,6.25c1.43,1.29 3.24,2.05 5.16,2.19 0.65,-0.01 1.29,-0.17 1.88,-0.47 1.09,-0.47 2.5,-1.72 2.5,-4.84 0.06,-0.85 -0.04,-1.7 -0.31,-2.5 2.08,0.89 3.48,2.89 3.59,5.16 -0.05,0.43 0.1,0.86 0.4,1.16 0.3,0.3 0.73,0.45 1.16,0.4 0.83,-0.07 1.49,-0.73 1.56,-1.56 -0.31,-5.47 -4.53,-8.28 -8.59,-9.22zM82.5,101.41c-1.32,0.13 -2.64,-0.33 -3.59,-1.25 -1.41,-1.25 -2.03,-2.5 -1.88,-3.13s1.88,-1.41 4.06,-1.41h1.25c0.64,1.09 0.96,2.33 0.94,3.59 0,0.78 -0.16,1.88 -0.78,2.19zM40.31,88.75c0,0.41 -0.16,0.81 -0.46,1.11s-0.69,0.46 -1.11,0.46h-2.97v2.97c0,0.41 -0.16,0.81 -0.46,1.11s-0.69,0.46 -1.11,0.46c-0.83,-0.07 -1.49,-0.73 -1.56,-1.56v-2.97h-2.81c-0.43,0.05 -0.86,-0.1 -1.16,-0.4 -0.3,-0.3 -0.45,-0.73 -0.4,-1.16 0,-0.86 0.7,-1.56 1.56,-1.56h2.81v-2.81c0,-0.86 0.7,-1.56 1.56,-1.56 0.43,-0.05 0.86,0.1 1.16,0.4 0.3,0.3 0.45,0.73 0.4,1.16v2.81h2.97c0.83,0.07 1.49,0.73 1.56,1.56zM52.5,23.13c-0.22,-0.37 -0.28,-0.82 -0.16,-1.23 0.12,-0.41 0.4,-0.76 0.79,-0.96l2.5,-1.41 -1.56,-2.5c-0.22,-0.37 -0.28,-0.82 -0.16,-1.23 0.12,-0.41 0.4,-0.76 0.79,-0.96 0.74,-0.36 1.62,-0.08 2.03,0.63l1.56,2.5 2.34,-1.56c0.37,-0.22 0.82,-0.28 1.23,-0.16 0.41,0.12 0.76,0.4 0.96,0.79 0.38,0.7 0.18,1.57 -0.47,2.03l-2.5,1.56 1.41,2.5c0.23,0.32 0.3,0.73 0.21,1.12 -0.09,0.39 -0.34,0.71 -0.68,0.91l-0.78,0.31c-0.57,-0.01 -1.1,-0.3 -1.41,-0.78l-1.41,-2.5 -2.5,1.41 -0.78,0.31c-0.56,-0.04 -1.08,-0.32 -1.41,-0.78z" /> diff --git a/core/ui/src/main/res/values-bn/strings.xml b/core/ui/src/main/res/values-bn/strings.xml index df2f6441..faada8bf 100644 --- a/core/ui/src/main/res/values-bn/strings.xml +++ b/core/ui/src/main/res/values-bn/strings.xml @@ -235,4 +235,5 @@ আগের শুরু করুন %s এ স্বাগত + লিস্টে ফিরে যান \ No newline at end of file diff --git a/core/ui/src/main/res/values-hi/strings.xml b/core/ui/src/main/res/values-hi/strings.xml index 92b60e43..35daefc7 100644 --- a/core/ui/src/main/res/values-hi/strings.xml +++ b/core/ui/src/main/res/values-hi/strings.xml @@ -235,4 +235,5 @@ पिछला जारी रखें %s में आपका स्वागत है + लिस्ट पर वापस जाएँ \ No newline at end of file diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 2930ba3e..d58ee03e 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -268,4 +268,5 @@ Previous Continue Welcome to %s + Go back to List \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt index c7029eaa..d7043100 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt @@ -88,7 +88,10 @@ internal fun AudioEditorScreenContainer( loadState = loadState, onSuccess = content, onFailed = { - AudioFileNotFoundBox(modifier = Modifier.align(Alignment.Center)) + AudioFileNotFoundBox( + onNavigateToList = {}, + modifier = Modifier.align(Alignment.Center) + ) }, ) } diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/AudioFileNotFoundBox.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/AudioFileNotFoundBox.kt index e80b7f6f..831fb61e 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/AudioFileNotFoundBox.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/AudioFileNotFoundBox.kt @@ -7,7 +7,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -17,13 +18,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.eva.ui.R import com.eva.ui.theme.RecorderAppTheme @Composable -fun AudioFileNotFoundBox(modifier: Modifier = Modifier) { +fun AudioFileNotFoundBox( + onNavigateToList: () -> Unit, + modifier: Modifier = Modifier +) { Column( modifier = modifier.defaultMinSize(minWidth = 200.dp, minHeight = 260.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -33,12 +38,13 @@ fun AudioFileNotFoundBox(modifier: Modifier = Modifier) { painter = painterResource(id = R.drawable.ic_music_file), contentDescription = stringResource(id = R.string.music_file_not_found_title), colorFilter = ColorFilter.tint(color = MaterialTheme.colorScheme.primary), - modifier = Modifier.size(180.dp) + modifier = Modifier.sizeIn(minWidth = 280.dp, minHeight = 280.dp) ) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(id = R.string.music_file_not_found_title), style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground ) Text( @@ -46,6 +52,13 @@ fun AudioFileNotFoundBox(modifier: Modifier = Modifier) { style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onBackground ) + Spacer(modifier = Modifier.height(20.dp)) + Button( + onClick = onNavigateToList, + shape = MaterialTheme.shapes.medium + ) { + Text(text = stringResource(R.string.move_back_to_list)) + } } } @@ -54,6 +67,7 @@ fun AudioFileNotFoundBox(modifier: Modifier = Modifier) { private fun AudioFileNotFoundBoxPreview() = RecorderAppTheme { Surface { AudioFileNotFoundBox( + onNavigateToList = {}, modifier = Modifier.padding(20.dp) ) } diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/ContentStateAnimatedContainer.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/ContentStateAnimatedContainer.kt index e861908f..3a6a94b2 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/ContentStateAnimatedContainer.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/ContentStateAnimatedContainer.kt @@ -3,16 +3,12 @@ package com.eva.player_shared.composables import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -43,7 +39,7 @@ fun ContentStateAnimatedContainer( derivedStateOf { when (loadState) { is ContentLoadState.Content -> PlainContentState.IS_SUCCESS - ContentLoadState.Loading -> PlainContentState.IS_SUCCESS + ContentLoadState.Loading -> PlainContentState.IS_LOADING ContentLoadState.Unknown -> PlainContentState.IS_ERROR } } @@ -81,34 +77,42 @@ private enum class PlainContentState { IS_ERROR, } -private fun AnimatedContentTransitionScope.animateLoadState(): ContentTransform { - val loadContentTransition: FiniteAnimationSpec = tween( - durationMillis = 800, - easing = FastOutSlowInEasing - ) +private fun AnimatedContentTransitionScope.animateLoadState( + transitionDuration: Int = 250, + delayDuration: Int = 50 +): ContentTransform { + return when (initialState) { + PlainContentState.IS_LOADING if targetState == PlainContentState.IS_SUCCESS -> { + fadeIn( + animationSpec = tween( + durationMillis = transitionDuration, + delayMillis = delayDuration + ) + ) + scaleIn( + initialScale = 0.9f, + animationSpec = tween( + durationMillis = transitionDuration, + delayMillis = delayDuration, + easing = FastOutSlowInEasing + ) + ) togetherWith fadeOut(animationSpec = tween(durationMillis = transitionDuration / 2)) + } - val normalTransition: FiniteAnimationSpec = tween( - durationMillis = 200, - delayMillis = 60, - easing = FastOutLinearInEasing - ) + PlainContentState.IS_LOADING if targetState == PlainContentState.IS_ERROR -> { + slideInVertically( + animationSpec = tween( + durationMillis = transitionDuration, + easing = FastOutSlowInEasing + ), + initialOffsetY = { fullHeight -> -fullHeight / 4 } + ) + fadeIn( + animationSpec = tween(durationMillis = transitionDuration) + ) togetherWith fadeOut(animationSpec = tween(durationMillis = transitionDuration / 2)) + } - return if (initialState == PlainContentState.IS_LOADING && targetState == PlainContentState.IS_SUCCESS) { - fadeIn(animationSpec = loadContentTransition) + expandVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - expandFrom = Alignment.CenterVertically, - ) togetherWith - fadeOut(loadContentTransition) + shrinkVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - shrinkTowards = Alignment.CenterVertically, - ) - } else fadeIn(normalTransition) togetherWith fadeOut(normalTransition) + else -> fadeIn(animationSpec = tween(durationMillis = transitionDuration)) togetherWith + fadeOut(animationSpec = tween(durationMillis = transitionDuration)) + } } class ContentLoadStatePreviewParams : CollectionPreviewParameterProvider( diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt index 7dcaf14f..5eff7af8 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt @@ -25,6 +25,7 @@ import com.eva.player_shared.PlayerMetadataViewmodel import com.eva.player_shared.PlayerVisualizerViewmodel import com.eva.ui.R import com.eva.ui.navigation.NavDialogs +import com.eva.ui.navigation.NavRoutes import com.eva.ui.navigation.PlayerSubGraph import com.eva.ui.navigation.animatedComposable import com.eva.ui.utils.LocalSharedTransitionVisibilityScopeProvider @@ -113,6 +114,11 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = controller.navigate(dialog) } }, + onNavigateToRecordings = { + controller.navigate(NavRoutes.VoiceRecordings) { + popUpTo() + } + }, navigation = { if (controller.previousBackStackEntry?.destination?.route != null) { IconButton( diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt index 23310277..b888e4b8 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt @@ -81,6 +81,7 @@ internal fun AudioPlayerScreen( navigation: @Composable () -> Unit = {}, onNavigateToEdit: () -> Unit = {}, onRenameItem: (Long) -> Unit = {}, + onNavigateToRecordings: () -> Unit = {}, ) { val snackBarProvider = LocalSnackBarProvider.current @@ -142,11 +143,14 @@ internal fun AudioPlayerScreen( ) }, onFailed = { - AudioFileNotFoundBox(modifier = Modifier.align(Alignment.Center)) + AudioFileNotFoundBox( + onNavigateToList = onNavigateToRecordings, + modifier = Modifier.align(Alignment.Center) + ) }, onLoading = { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.align(Alignment.Center) ) { From be40cd054990fa849edf9b69b4adabf76011f0b2 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Mon, 8 Dec 2025 01:08:45 +0530 Subject: [PATCH 5/9] Correcting player flows Player flows were configured incorrectly the initial state was not based on the current player instance but the default one in PlayerIsPlayingFlow.kt initial is made based on the playerState and isPlaying flag Similarly in PlayerTrackDataFlow.kt if the track is already being played there was no listener for it so track data was not being emitted So initially we check if its playing state then we start the periodic updates --- .../player/data/util/PlayerIsPlayingFlow.kt | 14 +++-- .../player/data/util/PlayerTrackDataFlow.kt | 54 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt index c8369d1e..d505ca65 100644 --- a/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt +++ b/data/player/src/main/java/com/eva/player/data/util/PlayerIsPlayingFlow.kt @@ -13,11 +13,19 @@ import kotlinx.coroutines.flow.map private const val TAG = "PLAYER_PLAYING_FLOW" +private fun Player.currentPlayState() = when { + playbackState == Player.STATE_BUFFERING -> PlayerPlayState.BUFFERING + isPlaying -> PlayerPlayState.PLAYING + else -> PlayerPlayState.PAUSED +} + fun Player.computePlayerPlayState(): Flow = callbackFlow { var isSeeking = false // initially send a false - trySend(PlayerPlayState.PAUSED) + val initial = currentPlayState() + trySend(initial) + Log.d(TAG, "INITIAL STATE $initial") val listener = object : Player.Listener { @@ -33,7 +41,7 @@ fun Player.computePlayerPlayState(): Flow = callbackFlow { override fun onIsPlayingChanged(isPlaying: Boolean) { // skip is playing condition if the player is seeking if (isSeeking) return - val state = if (isPlaying) PlayerPlayState.PLAYING else PlayerPlayState.PAUSED + val state = currentPlayState() Log.d(TAG, "PLAYER IS PLAYING CHANGED :$state") trySend(state) } @@ -56,7 +64,7 @@ fun Player.computePlayerPlayState(): Flow = callbackFlow { override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { if (reason != Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) return - val state = if (playWhenReady) PlayerPlayState.PLAYING else PlayerPlayState.PAUSED + val state = currentPlayState() Log.d(TAG, "PLAY WHEN READY CHANGED :$state") trySend(state) } diff --git a/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt b/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt index 735eaa15..79c5f3c0 100644 --- a/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt +++ b/data/player/src/main/java/com/eva/player/data/util/PlayerTrackDataFlow.kt @@ -31,10 +31,29 @@ fun Player.computePlayerTrackData( // first emission launch { val trackData = this@computePlayerTrackData.toTrackData() + Log.d(TAG, "INITIAL DATA :$trackData") + // send only if there is some duration info available + // if invalid will be filter out later send(trackData) } var job: Job? = null + fun startPeriodicUpdates() { + job?.cancel() + job = launch { + try { + while (isActive) { + val trackData = toTrackData() + if (trackData.allPositiveAndFinite) trySend(trackData) + delay(delayDuration) + } + } catch (_: CancellationException) { + Log.d(TAG, "ADVERTISE COROUTINE CANCELLED") + } catch (e: Exception) { + e.printStackTrace() + } + } + } val listener = object : Player.Listener { @@ -63,23 +82,18 @@ fun Player.computePlayerTrackData( } override fun onIsPlayingChanged(isPlaying: Boolean) { - // cancel the old coroutine - job?.cancel() - // if playing launch a new job to observe - job = launch(Dispatchers.Main) { - try { - // advertise data if its active and canLoop - while (isPlaying && isActive) { - val trackData = this@computePlayerTrackData.toTrackData() - if (!trackData.allPositiveAndFinite) continue - send(trackData) - // create a delay - delay(delayDuration) - } - } catch (_: CancellationException) { - Log.d(TAG, "CANNOT ADVERTISE DATA ANY MORE COROUTINE CANCELLED") - } catch (e: Exception) { - e.printStackTrace() + if (isPlaying) { + Log.d(TAG, "STARTING PERIODIC UPDATES") + startPeriodicUpdates() + } else { + Log.d(TAG, "PERIODIC UPDATES STOPPED") + // cancel the old coroutine + // and emit current track info + job?.cancel() + job = null + launch { + val track = this@computePlayerTrackData.toTrackData() + send(track) } } } @@ -129,6 +143,12 @@ fun Player.computePlayerTrackData( Log.d(TAG, "LISTENER ADDED") addListener(listener) + // if this already playing start periodic updates + if (isPlaying) { + Log.d(TAG, "PLAYER IS ALREADY PLAYING SO PERIODIC UPDATES") + startPeriodicUpdates() + } + //remove the listener awaitClose { job?.cancel() From bf15d5888fff3ef22dcadf37dd481d06119d8eec Mon Sep 17 00:00:00 2001 From: tuuhin Date: Mon, 8 Dec 2025 22:52:12 +0530 Subject: [PATCH 6/9] Changed in AudioVisualizerImpl.kt and thread controllers First ThreadControllerModule.kt is not activity retained scoped not a singleton ThreadController.kt included stop thread call, this calls checks and stop the handler thread produced via bind to lifecycle In ThreadController.kt we stop the thread if lifecycle is on destroy or stop thread is called HandlerExt.kt provides extension over the handler thread MediaCodecPCMDataDecoder.kt initiate extraction is done on io extractor, and the handler thread is check if alive then only decoder is created,if any of the handler.post call fails then normal call is made ensuring what is initialed should always clean ControllerLifeCycleObserver.kt is now bounded to start-stop lifecycle In AudioVisualizerImpl.kt stoping the thread by call when the extraction is done rather than waiting for on destroy lifecycle event --- .../visualizer/data/AudioVisualizerImpl.kt | 62 +++++++-------- .../data/MediaCodecPCMDataDecoder.kt | 75 ++++++++++++------- .../data/ThreadLifecycleControllerImpl.kt | 63 +++++++++++----- .../visualizer/di/ThreadControllerModule.kt | 8 +- .../com/visualizer/domain/ThreadController.kt | 22 +++++- .../com/com/visualizer/utils/HandlerExt.kt | 17 +++++ .../composable/ControllerLifeCycleObserver.kt | 6 +- 7 files changed, 168 insertions(+), 85 deletions(-) create mode 100644 data/visualizer/src/main/java/com/com/visualizer/utils/HandlerExt.kt diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt b/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt index 96a539d3..58152ed6 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext private const val TAG = "PLAIN_VISUALIZER" @@ -57,8 +56,7 @@ internal class AudioVisualizerImpl( fileUri: String, lifecycleOwner: LifecycleOwner, timePerPointInMs: Int - ): Result { - + ): Result = _lock.withLock { if (_decoder != null) { Log.d(TAG, "CLEAN DECODER TO PREPARE IT AGAIN") return Result.failure(DecoderExistsException()) @@ -66,23 +64,27 @@ internal class AudioVisualizerImpl( val handler = threadHandler.bindToLifecycle(lifecycleOwner) - return withContext(Dispatchers.IO) { - try { - _lock.withLock { - _decoder = MediaCodecPCMDataDecoder( - handler = handler, - seekDurationMillis = timePerPointInMs, - ) - } - _decoder?.setOnBufferDecode(::updateVisuals) - _decoder?.setOnComplete(::releaseObjects) - _decoder?.initiateExtraction(context, fileUri.toUri()) - - Result.success(Unit) - } catch (e: Exception) { - Log.e(TAG, "CANNOT DECODE THIS URI", e) - Result.failure(e) + return try { + val decoder = MediaCodecPCMDataDecoder( + handler = handler, + seekDurationMillis = timePerPointInMs, + ).also { _decoder = it } + + Log.i(TAG, "MEDIA CODEC DECODER CREATED") + + // setup callbacks + decoder.setOnBufferDecode(::updateVisuals) + decoder.setOnComplete { + Log.d(TAG, "DECODER JOB IS DONE RELEASING THE HANDLER") + // release the objects + releaseDecoder() + // decoder work is done so we can kill it now + if (handler != null) threadHandler.stopThread(handler) } + decoder.initiateExtraction(context, fileUri.toUri()) + } catch (e: Exception) { + Log.e(TAG, "CANNOT DECODE THIS URI", e) + Result.failure(e) } } @@ -91,24 +93,22 @@ internal class AudioVisualizerImpl( _visualization.update { it + array } } - private fun releaseObjects() { - if (_lock.tryLock()) { - try { - Log.d(TAG, "CLEARING UP OBJECTS") - _decoder?.cleanUp() - } finally { - _decoder = null - _lock.unlock() - } + private fun releaseDecoder() { + try { + _decoder?.cleanUp() + } finally { + Log.d(TAG, "DECODER CLEANED!") + _decoder = null + _isReady.update { VisualizerState.FINISHED } } - _isReady.update { VisualizerState.FINISHED } } override fun cleanUp() { + Log.d(TAG, "CLEARING UP THE VISUALIZER") + // release the objects + releaseDecoder() // reset values _visualization.update { floatArrayOf() } - // release the objects - releaseObjects() } } \ No newline at end of file diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecPCMDataDecoder.kt b/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecPCMDataDecoder.kt index 5af25f2e..7ea51f91 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecPCMDataDecoder.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecPCMDataDecoder.kt @@ -11,6 +11,8 @@ import android.os.Looper import android.util.Log import com.com.visualizer.domain.exception.ExtractorNoTrackFoundException import com.com.visualizer.domain.exception.InvalidMimeTypeException +import com.com.visualizer.utils.isThreadAlive +import com.com.visualizer.utils.safePOST import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -43,6 +45,8 @@ internal class MediaCodecPCMDataDecoder( private val handler: Handler? = null, ) : MediaCodec.Callback() { + val ioDispatcher = Dispatchers.IO.limitedParallelism(1) + @Volatile private var _mediaCodec: MediaCodec? = null @@ -187,7 +191,7 @@ internal class MediaCodecPCMDataDecoder( } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - Log.i(CODEC_TAG, "MEDIA FORMAT CHANGED: $format") + Log.d(CODEC_TAG, "MEDIA FORMAT CHANGED: $format") } private fun handleFloatArray(pcm: FloatArray) { @@ -265,28 +269,27 @@ internal class MediaCodecPCMDataDecoder( _onDecodeComplete = listener } - @Synchronized - fun initiateExtraction(context: Context, fileURI: Uri): Result { - _extractor?.release() - _extractor = MediaExtractor().apply { - setDataSource(context, fileURI, null) - } - val format = _extractor?.getTrackFormat(0) - val mimeType = format?.mimeType - - if (mimeType == null || !mimeType.startsWith("audio")) - return Result.failure(InvalidMimeTypeException()) + suspend fun initiateExtraction(context: Context, fileURI: Uri): Result = + withContext(ioDispatcher) { + _extractor?.release() + _extractor = MediaExtractor().apply { + setDataSource(context, fileURI, null) + } + val format = _extractor?.getTrackFormat(0) + val mimeType = format?.mimeType - if (_extractor?.trackCount == 0) - return Result.failure(ExtractorNoTrackFoundException()) + if (mimeType == null || !mimeType.startsWith("audio")) + return@withContext Result.failure(InvalidMimeTypeException()) - Log.i(EXTRACTOR_TAG, "EXTRACTOR PREPARED") - _extractor?.selectTrack(0) - _totalTimeInMs.store(format.duration.inWholeMilliseconds) + if (_extractor?.trackCount == 0) + return@withContext Result.failure(ExtractorNoTrackFoundException()) - initiateCodec(format) - return Result.success(Unit) - } + Log.i(EXTRACTOR_TAG, "EXTRACTOR PREPARED") + _extractor?.selectTrack(0) + _totalTimeInMs.store(format.duration.inWholeMilliseconds) + initiateCodec(format) + Result.success(Unit) + } private fun initiateCodec(format: MediaFormat) { @@ -301,6 +304,11 @@ internal class MediaCodecPCMDataDecoder( _mediaCodec?.reset() _codecState = MediaCodecState.STOPPED + val isAlive = handler?.isThreadAlive() ?: false + + Log.i(CODEC_TAG, "HANDLER THREAD ALIVE :$isAlive") + if (!isAlive) return + // codec is configured val codecName = _codecList.findDecoderForFormat(format) _mediaCodec = MediaCodec.createByCodecName(codecName).apply { @@ -308,13 +316,20 @@ internal class MediaCodecPCMDataDecoder( configure(format, null, null, 0) } - Log.i(CODEC_TAG, "MEDIA CODEC CONFIGURED THREAD:${handler?.looper?.thread}") + Log.i(CODEC_TAG, "MEDIA CODEC CONFIGURED THREAD:${handler.looper.thread}") Log.d(CODEC_TAG, "MEDIA CODEC NAME :${_mediaCodec?.name}") - // codec is started - _mediaCodec?.start() - _codecState = MediaCodecState.EXEC - Log.i(CODEC_TAG, "MEDIA CODEC STARTED CURRENT STATE :$_codecState") + val caller = Runnable { + _mediaCodec?.start() + _codecState = MediaCodecState.EXEC + Log.i(CODEC_TAG, "MEDIA CODEC STARTED ON :${Thread.currentThread()}") + } + // start the codec + if (handler.looper == Looper.getMainLooper()) caller.run() + else { + val isPosted = handler.post(caller) + if (!isPosted) caller.run() + } } @@ -328,11 +343,13 @@ internal class MediaCodecPCMDataDecoder( Log.d(PROCESSING_TAG, "CANCELLING OPERATIONS SCOPE") _scope.cancel() + val caller = Runnable { codecClean() } + if (handler == null || handler.looper == Looper.getMainLooper()) { - codecClean() + caller.run() } else { - val isPosted = handler.post { codecClean() } - Log.i(CODEC_TAG, "CLEAN UP POSTED! :$isPosted") + val isPosted = handler.safePOST(caller) + if (!isPosted) caller.run() } } @@ -365,7 +382,7 @@ internal class MediaCodecPCMDataDecoder( Log.e(CODEC_TAG, "FAILED TO CLEAR CALLBACK", e) } - // release the codec + // release the codec try { _mediaCodec?.release() Log.i(CODEC_TAG, "MEDIA CODEC RELEASED") diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt b/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt index 39a87e3d..30a0964a 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt @@ -9,10 +9,13 @@ import androidx.annotation.MainThread import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.com.visualizer.domain.ThreadController +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.time.measureTime private const val TAG = "THREAD_CONTROLLER" +@OptIn(ExperimentalAtomicApi::class) internal class ThreadLifecycleControllerImpl(private val threadName: String) : DefaultLifecycleObserver, ThreadController { @@ -26,26 +29,52 @@ internal class ThreadLifecycleControllerImpl(private val threadName: String) : Log.e(TAG, "THREADING ERRORS NAME:${thread.name} STATE:${thread.state}", exc) } + private val _isStopping = AtomicBoolean(false) + override fun onDestroy(owner: LifecycleOwner) { - owner.lifecycle.removeObserver(this) - stopThread() + try { + val requested = _isStopping.load() + if (requested) return + if (_handler == null) return + Log.d(TAG, "STOP THREAD ON END_OF_LIFECYCLE") + stopThreadInternal(600L) + } finally { + _isStopping.store(false) + owner.lifecycle.addObserver(this@ThreadLifecycleControllerImpl) + Log.d(TAG, "THREAD OBSERVER REMOVED!") + } } @MainThread @Synchronized override fun bindToLifecycle(lifecycleOwner: LifecycleOwner): Handler { lifecycleOwner.lifecycle.addObserver(this@ThreadLifecycleControllerImpl) + Log.d(TAG, "NEW THREAD OBSERVER ADDED!!") return getHandler() } + override fun stopThread(handler: Handler?, maxWaitTime: Long) { + if (_handler?.looper?.thread?.thId != handler?.looper?.thread?.thId) { + Log.w(TAG, "INCORRECT HANDLER PROVIDED CANNOT STOP CURRENT") + Log.w(TAG, "CURRENT THREAD THREAD :${Thread.currentThread()}") + return + } + try { + val requested = _isStopping.load() + if (requested) return + if (_handler == null) return + Log.d(TAG, "STOP THREAD VIA CALL") + stopThreadInternal(maxWaitTime) + } finally { + _isStopping.store(false) + } + } + private fun getHandler(): Handler { if (_handlerThread == null || _handlerThread?.isAlive == false) createThread() return _handler!! } - /** - * Prepares the handler for use - */ @Suppress("DEPRECATION") @Synchronized private fun createThread() { @@ -62,14 +91,11 @@ internal class ThreadLifecycleControllerImpl(private val threadName: String) : _handlerThread = newThread _handler = Handler.createAsync(newThread.looper) - val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) - newThread.threadId() else newThread.id - val message = buildString { append("HANDLER THREAD IS SET: ") append("NAME: ${newThread.name} |") append("STATE: ${newThread.looper.thread.state} |") - append("ID: $threadId |") + append("ID: ${newThread.thId} |") append("PRIORITY :${newThread.priority}") } Log.i(TAG, message) @@ -80,16 +106,16 @@ internal class ThreadLifecycleControllerImpl(private val threadName: String) : * @param maxWaitTime Time in millis the thread should wait for thread to die */ @Synchronized - private fun stopThread(maxWaitTime: Long = 600L) { + private fun stopThreadInternal(maxWaitTime: Long) { require(maxWaitTime > 0) { "Wait time need to be greater than 0" } val handlerThread = _handlerThread ?: run { - Log.d(TAG, "HANDLER THREAD WAS NOT SET") + Log.w(TAG, "HANDLER THREAD WAS NOT SET OR ALREADY HANDLED") return } val handler = _handler ?: return handler.removeCallbacksAndMessages(null) - + Log.i(TAG, "STOPPING THREAD THREAD_ID:${handler.looper.thread.thId}") try { val safeRequest = if (handlerThread.isAlive) handlerThread.quitSafely() else false @@ -98,22 +124,25 @@ internal class ThreadLifecycleControllerImpl(private val threadName: String) : Log.d(TAG, "LOOPER WAS NOT SET OF THE THREAD IS ALREADY KILLED") return } - Log.i(TAG, "THREAD QUIT, THREAD STATE: ${handlerThread.state}") + Log.i(TAG, "THREAD STATE BEFORE JOIN: ${handlerThread.state}") // blocking code val duration = measureTime { handlerThread.join(maxWaitTime) } - Log.d(TAG, "THREAD CURRENT STATE: ${handlerThread.state}") Log.d(TAG, "JOIN TOOK :$duration") + Log.i(TAG, "THREAD STATE AFTER JOIN: ${handlerThread.state}") } catch (e: InterruptedException) { Log.e(TAG, "THREAD JOIN FAILED", e) e.printStackTrace() } finally { - Log.v(TAG, "AFTER CLEAN UP") - Log.v(TAG, "STATE: ${_handlerThread?.state}") + Log.i(TAG, "CLEANING UP DONE!") + Log.d(TAG, "AFTER CLEAN UP STATE: ${_handlerThread?.state}") _handlerThread?.uncaughtExceptionHandler = null _handlerThread = null _handler = null - // } } + + @Suppress("DEPRECATION") + private val Thread.thId: Long + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) threadId() else id } \ No newline at end of file diff --git a/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt b/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt index d2c2b4b4..e1a4fb41 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt @@ -5,14 +5,14 @@ import com.com.visualizer.domain.ThreadController import dagger.Module import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped @Module -@InstallIn(SingletonComponent::class) +@InstallIn(ActivityRetainedComponent::class) object ThreadControllerModule { @Provides - @Singleton + @ActivityRetainedScoped fun providesThread(): ThreadController = ThreadLifecycleControllerImpl("ComputeThread") } \ No newline at end of file diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt index 931ffa70..c3e59d1b 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt @@ -3,8 +3,28 @@ package com.com.visualizer.domain import android.os.Handler import androidx.lifecycle.LifecycleOwner -fun interface ThreadController { +/** + * An interface for controlling the lifecycle of a background thread, typically + * managing a [Handler] that posts tasks to that thread. + */ +interface ThreadController { + /** + * Binds the controlled thread's execution to the lifecycle of the given [lifecycleOwner]. + * + * @param lifecycleOwner The [LifecycleOwner] whose lifecycle determines the thread's lifespan. + * @return The [Handler] associated with the newly started background thread, + * or `null` if the thread could not be started. + */ fun bindToLifecycle(lifecycleOwner: LifecycleOwner): Handler? + /** + * Stops the background thread associated with the given [handler]. + * + * @param handler The [Handler] associated with the background thread to be stopped. + * This is typically the object returned by [bindToLifecycle]. + * @param maxWaitTime The maximum time (in milliseconds) to wait for the thread + * to complete its current tasks and stop before interrupting it. + */ + fun stopThread(handler: Handler?, maxWaitTime: Long = 600L) } \ No newline at end of file diff --git a/data/visualizer/src/main/java/com/com/visualizer/utils/HandlerExt.kt b/data/visualizer/src/main/java/com/com/visualizer/utils/HandlerExt.kt new file mode 100644 index 00000000..7eb2f805 --- /dev/null +++ b/data/visualizer/src/main/java/com/com/visualizer/utils/HandlerExt.kt @@ -0,0 +1,17 @@ +package com.com.visualizer.utils + +import android.os.Handler +import android.os.Looper + +fun Handler?.safePOST(runnable: Runnable): Boolean { + if (this == null) return false + if (!looper.thread.isAlive) return false + return post(runnable) +} + +fun Handler?.isThreadAlive(ignoreMainThread: Boolean = true): Boolean { + val isAlive = this?.looper?.thread?.isAlive ?: false + val isMainLooper = this?.looper == Looper.getMainLooper() + return if (isMainLooper && ignoreMainThread) false + else isAlive +} \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/ControllerLifeCycleObserver.kt b/feature/player/src/main/java/com/eva/feature_player/composable/ControllerLifeCycleObserver.kt index 1e52f7ea..ff70473c 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/ControllerLifeCycleObserver.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/ControllerLifeCycleObserver.kt @@ -3,7 +3,7 @@ package com.eva.feature_player.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState -import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LocalLifecycleOwner import com.eva.feature_player.state.ControllerEvents @@ -12,12 +12,12 @@ fun ControllerLifeCycleObserver(audioId: Long, onEvent: (ControllerEvents) -> Un val lifeCycleOwner = LocalLifecycleOwner.current val updatedEvent by rememberUpdatedState(onEvent) - LifecycleResumeEffect(key1 = lifeCycleOwner, key2 = audioId) { + LifecycleStartEffect(key1 = lifeCycleOwner, key2 = audioId) { // on resume updatedEvent(ControllerEvents.OnAddController(audioId)) // on pause - onPauseOrDispose { + onStopOrDispose { updatedEvent(ControllerEvents.OnRemoveController) } } From d1d69d60d941479fc0e18d4db3f3afb50d1798ca Mon Sep 17 00:00:00 2001 From: tuuhin Date: Tue, 9 Dec 2025 01:36:56 +0530 Subject: [PATCH 7/9] Audio Editor Route simplied Rather than passing the whole AudioFileModel in the viewmodel simplifying creating in EditorViewmodelFactory.kt which as all only takes a fileId later we read the file from media store PlayerSubGraph.AudioEditorRoute also needs audioId thus easier to call toRoute in AudioEditorRoute.kt New SharedElementModifiers.kt are added As like player viewmodel we set up the player and the current file then work on that similarly we do the same in here too AudioEditorRoute.kt and AudioEditorScreen.kt Simplified no more extra container and a stateful version we take the whole thing as one Moved the audioId out of AudioPlayerScreen.kt and used the shared bounds wrapper in AudioPlayerRoute.kt --- .../ui/animation/SharedElementModifiers.kt | 46 ++++- .../com/eva/ui/navigation/PlayerSubGraph.kt | 8 +- .../eva/feature_editor/AudioEditorRoute.kt | 152 ++++++-------- .../eva/feature_editor/AudioEditorScreen.kt | 193 ++++++++---------- .../viewmodel/AudioEditorViewModel.kt | 62 ++++-- .../viewmodel/EditorViewmodelFactory.kt | 3 +- .../eva/feature_player/AudioPlayerRoute.kt | 13 +- .../eva/feature_player/AudioPlayerScreen.kt | 18 +- .../composable/AudioPlayerScreenTopBar.kt | 11 +- 9 files changed, 255 insertions(+), 251 deletions(-) diff --git a/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt b/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt index 36da491a..b3b51602 100644 --- a/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt +++ b/core/ui/src/main/java/com/eva/ui/animation/SharedElementModifiers.kt @@ -1,21 +1,21 @@ -@file:OptIn(ExperimentalSharedTransitionApi::class) - package com.eva.ui.animation import androidx.compose.animation.BoundsTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Spring.StiffnessMediumLow import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import com.eva.ui.utils.LocalSharedTransitionScopeProvider import com.eva.ui.utils.LocalSharedTransitionVisibilityScopeProvider @@ -32,6 +32,7 @@ fun Modifier.sharedElementWrapper( zIndexInOverlay: Float = 0f, placeHolderSize: SharedTransitionScope.PlaceholderSize = SharedTransitionScope.PlaceholderSize.ContentSize, boundsTransform: BoundsTransform = BoundsTransform { _, _ -> NormalSpring }, + clipShape: Shape = RectangleShape, ) = composed { val transitionScope = LocalSharedTransitionScopeProvider.current ?: return@composed Modifier val visibilityScope = @@ -46,7 +47,8 @@ fun Modifier.sharedElementWrapper( renderInOverlayDuringTransition = renderInOverlayDuringTransition, zIndexInOverlay = zIndexInOverlay, placeholderSize = placeHolderSize, - boundsTransform = boundsTransform + boundsTransform = boundsTransform, + clipInOverlayDuringTransition = OverlayClip(clipShape) ) } } @@ -56,10 +58,14 @@ fun Modifier.sharedBoundsWrapper( enter: EnterTransition = fadeIn(), exit: ExitTransition = fadeOut(), renderInOverlayDuringTransition: Boolean = true, - resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.scaleToBounds(ContentScale.FillWidth, Center), + resizeMode: SharedTransitionScope.ResizeMode = SharedTransitionScope.ResizeMode.scaleToBounds( + ContentScale.FillWidth, + Center + ), zIndexInOverlay: Float = 0f, placeHolderSize: SharedTransitionScope.PlaceholderSize = SharedTransitionScope.PlaceholderSize.ContentSize, boundsTransform: BoundsTransform = BoundsTransform { _, _ -> NormalSpring }, + clipShape: Shape = RectangleShape, ) = composed { val transitionScope = LocalSharedTransitionScopeProvider.current ?: return@composed Modifier @@ -79,6 +85,36 @@ fun Modifier.sharedBoundsWrapper( zIndexInOverlay = zIndexInOverlay, placeholderSize = placeHolderSize, resizeMode = resizeMode, + clipInOverlayDuringTransition = OverlayClip(clipShape) ) } +} + +@Composable +fun Modifier.sharedTransitionSkipChildSize(): Modifier { + val transitionScope = LocalSharedTransitionScopeProvider.current ?: return this + + return with(transitionScope) { + this@sharedTransitionSkipChildSize.skipToLookaheadSize() + } +} + +@Composable +fun Modifier.sharedTransitionSkipChildPosition(): Modifier { + val transitionScope = LocalSharedTransitionScopeProvider.current ?: return this + + return with(transitionScope) { + this@sharedTransitionSkipChildPosition + .skipToLookaheadPosition() + } +} + + +@Composable +fun Modifier.sharedTransitionRenderInOverlay(zIndexInOverlay: Float): Modifier { + val transitionScope = LocalSharedTransitionScopeProvider.current ?: return this + return with(transitionScope) { + this@sharedTransitionRenderInOverlay + .renderInSharedTransitionScopeOverlay(zIndexInOverlay = zIndexInOverlay) + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/eva/ui/navigation/PlayerSubGraph.kt b/core/ui/src/main/java/com/eva/ui/navigation/PlayerSubGraph.kt index 6aa42d8c..591e1dfa 100644 --- a/core/ui/src/main/java/com/eva/ui/navigation/PlayerSubGraph.kt +++ b/core/ui/src/main/java/com/eva/ui/navigation/PlayerSubGraph.kt @@ -1,5 +1,6 @@ package com.eva.ui.navigation +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -7,12 +8,13 @@ sealed interface PlayerSubGraph { // we need audio id to mark to get the route data from saved state handle @Serializable - data class NavGraph(val audioId: Long) : PlayerSubGraph + data class NavGraph(@SerialName("audioId") val audioId: Long) : PlayerSubGraph // we need the audio id to let deep links work @Serializable - data class AudioPlayerRoute(val audioId: Long) : PlayerSubGraph + data class AudioPlayerRoute(@SerialName("audioId") val audioId: Long) : PlayerSubGraph + // we are using the audio id to set up the player again so audio id @Serializable - data object AudioEditorRoute : PlayerSubGraph + data class AudioEditorRoute(@SerialName("audioId") val audioId: Long) : PlayerSubGraph } \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt index 7772eb14..675c7955 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorRoute.kt @@ -4,28 +4,25 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import com.eva.editor.domain.AudioConfigToActionList +import androidx.navigation.toRoute import com.eva.feature_editor.viewmodel.AudioEditorViewModel import com.eva.feature_editor.viewmodel.EditorViewmodelFactory import com.eva.player_shared.PlayerMetadataViewmodel import com.eva.player_shared.PlayerVisualizerViewmodel -import com.eva.player_shared.util.PlayerGraphData -import com.eva.recordings.domain.models.AudioFileModel import com.eva.ui.R import com.eva.ui.navigation.NavRoutes import com.eva.ui.navigation.PlayerSubGraph @@ -39,105 +36,78 @@ import kotlinx.coroutines.flow.merge fun NavGraphBuilder.audioEditorRoute(controller: NavController) = animatedComposable { backstackEntry -> + val route = backstackEntry.toRoute() + val sharedViewmodel = backstackEntry.sharedViewmodel(controller) val visualsViewmodel = backstackEntry.sharedViewmodel(controller) + val editorViewModel = hiltViewModel( + creationCallback = { factory -> factory.create(route.audioId) }, + ) + val loadState by sharedViewmodel.loadState.collectAsStateWithLifecycle() val compressedVisuals by visualsViewmodel.compressedVisuals.collectAsStateWithLifecycle() val isVisualsReady by visualsViewmodel.isVisualsReady.collectAsStateWithLifecycle() + val isPlaying by editorViewModel.isPlayerPlaying.collectAsStateWithLifecycle() + val trackData by editorViewModel.trackData.collectAsStateWithLifecycle() + val clipConfig by editorViewModel.clipConfig.collectAsStateWithLifecycle() + val transformationState by editorViewModel.transformationState.collectAsStateWithLifecycle() + val undoRedoState by editorViewModel.undoRedoState.collectAsStateWithLifecycle() + + val totalConfigs by editorViewModel.clipConfigs.collectAsStateWithLifecycle() + val isMediaEdited by remember(totalConfigs) { + derivedStateOf { totalConfigs.count() >= 1 } + } + + val lifecycleOwner = LocalLifecycleOwner.current + // ui events handler UiEventsHandler( - eventsFlow = { merge(sharedViewmodel.uiEvent, visualsViewmodel.uiEvent) }, + eventsFlow = { + merge( + sharedViewmodel.uiEvent, + visualsViewmodel.uiEvent, + editorViewModel.uiEvent + ) + }, ) + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + editorViewModel.exportBegun.collectLatest { + // handle nav event + controller.navigate(NavRoutes.VoiceRecordings) { + popUpTo { + inclusive = true + } + } + } + } + } + CompositionLocalProvider(LocalSharedTransitionVisibilityScopeProvider provides this) { - AudioEditorScreenContainer( + AudioEditorScreen( loadState = loadState, - content = { model -> - AudioEditorScreenStateful( - fileModel = model, - visualization = { compressedVisuals }, - isVisualsReady = isVisualsReady, - onClipDataUpdate = visualsViewmodel::updateClipConfigs, - onExportStarted = dropUnlessResumed { - controller.navigate(NavRoutes.VoiceRecordings) { - popUpTo { - inclusive = true - } - } - }, - navigation = { - if (controller.previousBackStackEntry != null) { - IconButton(onClick = dropUnlessResumed(block = controller::popBackStack)) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(R.string.back_arrow) - ) - } - } - }, - ) + trackData = { trackData }, + graphData = { compressedVisuals }, + onEvent = editorViewModel::onEvent, + isVisualsReady = isVisualsReady, + isPlaying = isPlaying, + clipConfig = clipConfig, + isMediaEdited = isMediaEdited, + undoRedoState = undoRedoState, + transformationState = transformationState, + navigation = { + if (controller.previousBackStackEntry != null) { + IconButton(onClick = dropUnlessResumed(block = controller::popBackStack)) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(R.string.back_arrow) + ) + } + } }, ) } } - -@Composable -private fun AudioEditorScreenStateful( - fileModel: AudioFileModel, - visualization: PlayerGraphData, - onClipDataUpdate: (AudioConfigToActionList) -> Unit, - onExportStarted: () -> Unit, - modifier: Modifier = Modifier, - isVisualsReady: Boolean = false, - navigation: @Composable () -> Unit = {}, -) { - - val lifecyleOwner = LocalLifecycleOwner.current - val currentOnClipDataUpdate by rememberUpdatedState(onClipDataUpdate) - val currentOnExportStarted by rememberUpdatedState(onExportStarted) - - val viewModel = hiltViewModel( - creationCallback = { factory -> factory.create(fileModel) }, - ) - - UiEventsHandler(eventsFlow = viewModel::uiEvent) - - LaunchedEffect(lifecyleOwner) { - viewModel.clipConfigs.collectLatest { - currentOnClipDataUpdate(it) - } - } - - LaunchedEffect(lifecyleOwner) { - viewModel.exportBegun.collectLatest { - currentOnExportStarted() - } - } - - val isPlaying by viewModel.isPlayerPlaying.collectAsStateWithLifecycle() - val trackData by viewModel.trackData.collectAsStateWithLifecycle() - val clipConfig by viewModel.clipConfig.collectAsStateWithLifecycle() - val transformationState by viewModel.transformationState.collectAsStateWithLifecycle() - val undoRedoState by viewModel.undoRedoState.collectAsStateWithLifecycle() - - val totalConfigs by viewModel.clipConfigs.collectAsStateWithLifecycle() - val isMediaEdited by remember(totalConfigs) { - derivedStateOf { totalConfigs.count() >= 1 } - } - - AudioEditorScreen( - graphData = visualization, - isVisualsReady = isVisualsReady, - isPlaying = isPlaying, - clipConfig = clipConfig, - trackData = { trackData }, - isMediaEdited = isMediaEdited, - undoRedoState = undoRedoState, - transformationState = transformationState, - onEvent = viewModel::onEvent, - modifier = modifier, - navigation = navigation, - ) -} \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt index d7043100..7a3d0826 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/AudioEditorScreen.kt @@ -1,17 +1,10 @@ package com.eva.feature_editor -import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset @@ -20,9 +13,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.Surface import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -59,47 +52,19 @@ import com.eva.recordings.domain.models.AudioFileModel import com.eva.ui.R import com.eva.ui.animation.SharedElementTransitionKeys import com.eva.ui.animation.sharedBoundsWrapper +import com.eva.ui.animation.sharedTransitionSkipChildPosition +import com.eva.ui.animation.sharedTransitionSkipChildSize import com.eva.ui.theme.DownloadableFonts import com.eva.ui.theme.RecorderAppTheme import com.eva.ui.utils.LocalSnackBarProvider import kotlinx.coroutines.launch - -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalSharedTransitionApi::class -) -@Composable -internal fun AudioEditorScreenContainer( - loadState: ContentLoadState, - content: @Composable BoxScope.(AudioFileModel) -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier - .fillMaxSize() - .sharedBoundsWrapper( - key = SharedElementTransitionKeys.RECORDING_EDITOR_SHARED_BOUNDS, - resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - enter = fadeIn(animationSpec = tween(easing = EaseOut, durationMillis = 300)), - exit = fadeOut(animationSpec = tween(easing = EaseOut, durationMillis = 300)), - ) - ) { - ContentStateAnimatedContainer( - loadState = loadState, - onSuccess = content, - onFailed = { - AudioFileNotFoundBox( - onNavigateToList = {}, - modifier = Modifier.align(Alignment.Center) - ) - }, - ) - } -} +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AudioEditorScreen( + loadState: ContentLoadState, trackData: () -> PlayerTrackData, graphData: PlayerGraphData, onEvent: (EditorScreenEvent) -> Unit, @@ -145,64 +110,86 @@ internal fun AudioEditorScreen( onRedoAction = { onEvent(EditorScreenEvent.OnRedoEdit) }, onUndoAction = { onEvent(EditorScreenEvent.OnUndoEdit) }, navigation = navigation, + modifier = Modifier.sharedTransitionSkipChildSize() ) }, snackbarHost = { SnackbarHost(snackBarHostProvider) }, - modifier = modifier, + modifier = modifier.sharedBoundsWrapper( + key = SharedElementTransitionKeys.RECORDING_EDITOR_SHARED_BOUNDS, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + clipShape = MaterialTheme.shapes.large + ), ) { scPadding -> - Box( + ContentStateAnimatedContainer( + loadState = loadState, + onSuccess = { model -> + PlayerDurationText( + track = { + val track = trackData() + if (track.allPositiveAndFinite) track + else PlayerTrackData(Duration.ZERO, model.duration) + }, + fontFamily = DownloadableFonts.SPLINE_SANS_MONO_FONT_FAMILY, + modifier = Modifier.align(Alignment.TopCenter) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center) + .offset(y = (-80).dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PlayerTrimSelector( + graphData = graphData, + trackData = { + val track = trackData() + if (track.allPositiveAndFinite) track + else PlayerTrackData(Duration.ZERO, model.duration) + }, + enabled = isVisualsReady, + clipConfig = clipConfig, + onClipConfigChange = { onEvent(EditorScreenEvent.OnClipConfigChange(it)) }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = dimensionResource(R.dimen.graph_card_padding), + vertical = dimensionResource(R.dimen.graph_card_padding_other) + ) + ) + AudioClipChipRow( + clipConfig = clipConfig, + onEvent = onEvent, + trackDuration = totalTrackDuration + ) + } + Box( + modifier = Modifier + .heightIn(min = 180.dp) + .fillMaxWidth() + .align(Alignment.BottomCenter) + .offset(y = (-20).dp), + contentAlignment = Alignment.Center + ) { + EditorActionsAndControls( + trackData = trackData, + isMediaPlaying = isPlaying, + onEvent = onEvent, + modifier = Modifier.fillMaxWidth() + ) + } + }, + onFailed = { + AudioFileNotFoundBox( + onNavigateToList = {}, + modifier = Modifier.align(Alignment.Center) + ) + }, modifier = Modifier .padding(scPadding) .padding(all = dimensionResource(id = R.dimen.sc_padding)) - .fillMaxSize(), - ) { - PlayerDurationText( - track = trackData, - fontFamily = DownloadableFonts.SPLINE_SANS_MONO_FONT_FAMILY, - modifier = Modifier.align(Alignment.TopCenter) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .offset(y = (-80).dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - PlayerTrimSelector( - graphData = graphData, - trackData = trackData, - enabled = isVisualsReady, - clipConfig = clipConfig, - onClipConfigChange = { onEvent(EditorScreenEvent.OnClipConfigChange(it)) }, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues( - horizontal = dimensionResource(R.dimen.graph_card_padding), - vertical = dimensionResource(R.dimen.graph_card_padding_other) - ) - ) - AudioClipChipRow( - clipConfig = clipConfig, - onEvent = onEvent, - trackDuration = totalTrackDuration - ) - } - Box( - modifier = Modifier - .heightIn(min = 180.dp) - .fillMaxWidth() - .align(Alignment.BottomCenter) - .offset(y = (-20).dp), - contentAlignment = Alignment.Center - ) { - EditorActionsAndControls( - trackData = trackData, - isMediaPlaying = isPlaying, - onEvent = onEvent, - modifier = Modifier.fillMaxWidth() - ) - } - } + .sharedTransitionSkipChildSize() + .sharedTransitionSkipChildPosition() + ) } } @@ -212,20 +199,16 @@ private fun AudioEditorScreenPreview( @PreviewParameter(ContentLoadStatePreviewParams::class) loadState: ContentLoadState, ) = RecorderAppTheme { - AudioEditorScreenContainer( + AudioEditorScreen( loadState = loadState, - content = { model -> - AudioEditorScreen( - trackData = { PlayerTrackData(total = model.duration) }, - graphData = { PlayerPreviewFakes.loadAmplitudeGraph(model.duration) }, - clipConfig = AudioClipConfig(end = model.duration), - onEvent = {}, - navigation = { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = "" - ) - }, + trackData = { PlayerTrackData(total = 10.seconds) }, + graphData = { PlayerPreviewFakes.loadAmplitudeGraph(10.seconds) }, + clipConfig = AudioClipConfig(end = 10.seconds), + onEvent = {}, + navigation = { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "" ) }, ) diff --git a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt index 9de14f1b..9c8da937 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/AudioEditorViewModel.kt @@ -14,6 +14,7 @@ import com.eva.feature_editor.undoredo.UndoRedoManager import com.eva.feature_editor.undoredo.UndoRedoState import com.eva.player.domain.model.PlayerTrackData import com.eva.recordings.domain.models.AudioFileModel +import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.ui.viewmodel.AppViewModel import com.eva.ui.viewmodel.UIEvents import dagger.assisted.Assisted @@ -41,12 +42,14 @@ import kotlin.time.Duration @HiltViewModel(assistedFactory = EditorViewmodelFactory::class) internal class AudioEditorViewModel @AssistedInject constructor( - @Assisted private val fileModel: AudioFileModel, + @Assisted private val audioId: Long, + private val fileProvider: PlayerFileProvider, private val transformer: AudioTransformer, private val saver: EditedItemSaver, private val player: SimpleAudioPlayer, ) : AppViewModel() { + private val _currentFile = MutableStateFlow(null) private val _lastEditAction = MutableStateFlow(AudioEditAction.CROP) private val _exportFileUri = MutableStateFlow(null) @@ -98,7 +101,7 @@ internal class AudioEditorViewModel @AssistedInject constructor( val trackData = player.trackInfoAsFlow.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(1_000L), - initialValue = PlayerTrackData(total = fileModel.duration) + initialValue = PlayerTrackData() ) private val _uiEvents = MutableSharedFlow() @@ -120,7 +123,18 @@ internal class AudioEditorViewModel @AssistedInject constructor( EditorScreenEvent.OnCancelTransformation -> cancelFinalExport() } - suspend fun setPlayerItem() = player.prepareAudioFile(fileModel) + fun setPlayerItem() = viewModelScope.launch { + val result = fileProvider.getAudioFileFromId(audioId, false) + result.fold( + onSuccess = { fileModel -> + _currentFile.update { fileModel } + player.prepareAudioFile(fileModel) + }, + onFailure = { err -> + _uiEvents.emit(UIEvents.ShowSnackBar(err.message ?: "")) + }, + ) + } fun hasMediaItemChanged() { player.isMediaItemChanged.onEach { @@ -136,7 +150,10 @@ internal class AudioEditorViewModel @AssistedInject constructor( private fun validateAndApplyEditViaAction(action: AudioEditAction) { viewModelScope.launch { val clipData = _clipData.value ?: return@launch - val trackData = trackData.value + val fileModel = _currentFile.value ?: return@launch + // use the fallback value from the file duration + val trackData = trackData.value.let { if (it.allPositiveAndFinite) it else null } + ?: PlayerTrackData(Duration.ZERO, fileModel.duration) if (clipData.start == Duration.ZERO && clipData.end == trackData.total) { val message = when (action) { @@ -171,34 +188,39 @@ internal class AudioEditorViewModel @AssistedInject constructor( } } - fun onUndoOrRedoConfigs(isUndo: Boolean) { - viewModelScope.launch { - // new clipping config - val clippingData = if (isUndo) _undoRedoManager.undo() - else _undoRedoManager.redo() + fun onUndoOrRedoConfigs(isUndo: Boolean) = viewModelScope.launch { + val fileModel = _currentFile.value ?: run { + _uiEvents.emit(UIEvents.ShowToast("No Audio model found")) + return@launch + } + // new clipping config + val clippingData = if (isUndo) _undoRedoManager.undo() + else _undoRedoManager.redo() - val filteredData = clippingData.filter { (config, _) -> config.hasMinimumDuration } + val filteredData = clippingData.filter { (config, _) -> config.hasMinimumDuration } - val result = player.editMediaPortions(fileModel, filteredData) - result.fold( - onSuccess = {}, - onFailure = { _uiEvents.emit(UIEvents.ShowSnackBar(it.message ?: "Some error")) }, - ) - } + val result = player.editMediaPortions(fileModel, filteredData) + result.fold( + onSuccess = {}, + onFailure = { _uiEvents.emit(UIEvents.ShowSnackBar(it.message ?: "Some error")) }, + ) } private fun updateClipConfig(clipConfig: AudioClipConfig) { - val track = trackData.value + val fileModel = _currentFile.value ?: return val clipData = _clipData.updateAndGet { clipConfig } ?: return - if (track.current in clipData.start..clipData.end) return + // again if track data is not + val trackData = this@AudioEditorViewModel.trackData.value.let { if (it.allPositiveAndFinite) it else null } + ?: PlayerTrackData(Duration.ZERO, fileModel.duration) + if (trackData.current in clipData.start..clipData.end) return if (!clipData.hasMinimumDuration) { val message = "Editor needs a ${AudioClipConfig.MIN_CLIP_DURATION} clip" viewModelScope.launch { _uiEvents.emit(UIEvents.ShowSnackBar(message)) } } - val seekDuration = with(trackData.value) { + val seekDuration = with(trackData) { val distanceToStart = abs(current.inWholeMilliseconds - clipData.start.inWholeMilliseconds) val distanceToEnd = abs(current.inWholeMilliseconds - clipData.end.inWholeMilliseconds) @@ -210,6 +232,7 @@ internal class AudioEditorViewModel @AssistedInject constructor( private fun onSaveExportFile() { + val fileModel = _currentFile.value ?: return val fileUri = _exportFileUri.value ?: return // will trigger a navigation event to recordings screen viewModelScope.launch { @@ -230,6 +253,7 @@ internal class AudioEditorViewModel @AssistedInject constructor( } private fun finalExport() { + val fileModel = _currentFile.value ?: return _exportJob?.cancel() _exportJob = viewModelScope.launch { diff --git a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/EditorViewmodelFactory.kt b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/EditorViewmodelFactory.kt index b8bfe982..4fc5f50c 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/EditorViewmodelFactory.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/viewmodel/EditorViewmodelFactory.kt @@ -1,10 +1,9 @@ package com.eva.feature_editor.viewmodel -import com.eva.recordings.domain.models.AudioFileModel import dagger.assisted.AssistedFactory @AssistedFactory internal interface EditorViewmodelFactory { - fun create(fileModel: AudioFileModel): AudioEditorViewModel + fun create(audioId: Long): AudioEditorViewModel } \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt index 5eff7af8..342055d3 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerRoute.kt @@ -1,12 +1,14 @@ package com.eva.feature_player import android.content.Intent +import androidx.compose.animation.SharedTransitionScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle @@ -24,6 +26,8 @@ import com.eva.feature_player.viewmodel.PlayerViewmodelFactory import com.eva.player_shared.PlayerMetadataViewmodel import com.eva.player_shared.PlayerVisualizerViewmodel import com.eva.ui.R +import com.eva.ui.animation.SharedElementTransitionKeys +import com.eva.ui.animation.sharedBoundsWrapper import com.eva.ui.navigation.NavDialogs import com.eva.ui.navigation.NavRoutes import com.eva.ui.navigation.PlayerSubGraph @@ -93,7 +97,6 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = CompositionLocalProvider(LocalSharedTransitionVisibilityScopeProvider provides this) { AudioPlayerScreen( - audioId = route.audioId, loadState = contentState, bookmarks = bookMarks, waveforms = { visuals }, @@ -106,7 +109,7 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = onPlayerEvents = playerViewModel::onPlayerEvents, onBookmarkEvent = bookmarkViewmodel::onBookMarkEvent, onNavigateToEdit = dropUnlessResumed { - controller.navigate(PlayerSubGraph.AudioEditorRoute) + controller.navigate(PlayerSubGraph.AudioEditorRoute(route.audioId)) }, onRenameItem = { audioId -> if (lifeCycleState.isAtLeast(Lifecycle.State.RESUMED)) { @@ -131,7 +134,11 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = } } }, - ) + modifier = Modifier.sharedBoundsWrapper( + key = SharedElementTransitionKeys.recordSharedEntryContainer(route.audioId), + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + ), + ) } } diff --git a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt index b888e4b8..2cca15ce 100644 --- a/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt +++ b/feature/player/src/main/java/com/eva/feature_player/AudioPlayerScreen.kt @@ -1,11 +1,5 @@ package com.eva.feature_player -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionScope -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -52,20 +46,16 @@ import com.eva.player_shared.util.AudioFileModelLoadState import com.eva.player_shared.util.PlayerGraphData import com.eva.player_shared.util.PlayerPreviewFakes import com.eva.ui.R -import com.eva.ui.animation.SharedElementTransitionKeys -import com.eva.ui.animation.sharedBoundsWrapper import com.eva.ui.theme.RecorderAppTheme import com.eva.ui.utils.LocalSnackBarProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @OptIn( - ExperimentalSharedTransitionApi::class, ExperimentalMaterial3Api::class ) @Composable internal fun AudioPlayerScreen( - audioId: Long, loadState: AudioFileModelLoadState, waveforms: PlayerGraphData, bookMarkState: CreateBookmarkState, @@ -115,12 +105,7 @@ internal fun AudioPlayerScreen( ) }, snackbarHost = { SnackbarHost(hostState = snackBarProvider) }, - modifier = modifier.sharedBoundsWrapper( - key = SharedElementTransitionKeys.recordSharedEntryContainer(audioId), - resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - enter = fadeIn(animationSpec = tween(easing = EaseOut, durationMillis = 300)), - exit = fadeOut(animationSpec = tween(easing = EaseOut, durationMillis = 300)), - ), + modifier = modifier, ) { scPadding -> ContentStateAnimatedContainer( loadState = loadState, @@ -173,7 +158,6 @@ private fun AudioPlayerScreenPreview( loadState: AudioFileModelLoadState ) = RecorderAppTheme { AudioPlayerScreen( - audioId = 0, loadState = loadState, waveforms = { PlayerPreviewFakes.PREVIEW_RECORDER_AMPLITUDES }, trackData = { PlayerTrackData(total = PlayerPreviewFakes.FAKE_AUDIO_MODEL.duration) }, diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt index 324512b1..163eb78d 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/AudioPlayerScreenTopBar.kt @@ -2,7 +2,7 @@ package com.eva.feature_player.composable import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -44,10 +44,7 @@ import com.eva.ui.R import com.eva.ui.animation.SharedElementTransitionKeys import com.eva.ui.animation.sharedBoundsWrapper -@OptIn( - ExperimentalMaterial3Api::class, - ExperimentalSharedTransitionApi::class -) +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun AudioPlayerScreenTopBar( loadState: ContentLoadState, @@ -130,7 +127,9 @@ internal fun AudioPlayerScreenTopBar( IconButton( onClick = onEdit, modifier = Modifier.sharedBoundsWrapper( - key = SharedElementTransitionKeys.RECORDING_EDITOR_SHARED_BOUNDS + key = SharedElementTransitionKeys.RECORDING_EDITOR_SHARED_BOUNDS, + resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, + clipShape = MaterialTheme.shapes.large ), ) { Icon( From bc0be30c602f5205f8b7d8a3c97fede4551f1d8e Mon Sep 17 00:00:00 2001 From: tuuhin Date: Tue, 9 Dec 2025 02:05:47 +0530 Subject: [PATCH 8/9] Navigation Animation changed Easing in navigation animations are changed --- .../eva/ui/navigation/AnimatedComposable.kt | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/core/ui/src/main/java/com/eva/ui/navigation/AnimatedComposable.kt b/core/ui/src/main/java/com/eva/ui/navigation/AnimatedComposable.kt index 32d8c0d4..08cd3ddb 100644 --- a/core/ui/src/main/java/com/eva/ui/navigation/AnimatedComposable.kt +++ b/core/ui/src/main/java/com/eva/ui/navigation/AnimatedComposable.kt @@ -5,10 +5,10 @@ import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.SizeTransform -import androidx.compose.animation.core.EaseIn -import androidx.compose.animation.core.EaseInCubic -import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.EaseOutCubic +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -25,30 +25,40 @@ inline fun NavGraphBuilder.animatedComposable( typeMap: Map> = emptyMap(), deepLinks: List = emptyList(), noinline sizeTransform: (AnimatedContentTransitionScope.() -> @JvmSuppressWildcards SizeTransform?)? = { - SizeTransform(clip = false) { _, _ -> spring() } + SizeTransform(clip = false) { _, _ -> spring(stiffness = Spring.StiffnessMediumLow) } }, noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) = composable( typeMap = typeMap, deepLinks = deepLinks, - enterTransition = { slideIntoContainerAndFadeIn }, - exitTransition = { slideOutOfContainerAndFadeOut }, - popEnterTransition = { slideIntoContainerAndFadeIn }, - popExitTransition = { slideOutOfContainerAndFadeOut }, + enterTransition = { slideInFromRightAndFadeIn }, + exitTransition = { slideOutToLeftAndFadeOut }, + popEnterTransition = { slideInFromLeftAndFadeIn }, + popExitTransition = { slideOutToRightAndFadeOut }, sizeTransform = sizeTransform, content = content ) -val AnimatedContentTransitionScope.slideIntoContainerAndFadeIn: EnterTransition +val AnimatedContentTransitionScope.slideInFromRightAndFadeIn: EnterTransition get() = slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(durationMillis = 300, easing = EaseInCubic) - ) + fadeIn(animationSpec = tween(easing = EaseIn, durationMillis = 300)) + animationSpec = tween(durationMillis = 300, easing = LinearEasing) + ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing, durationMillis = 300)) - -val AnimatedContentTransitionScope.slideOutOfContainerAndFadeOut: ExitTransition +val AnimatedContentTransitionScope.slideOutToLeftAndFadeOut: ExitTransition get() = slideOutOfContainer( AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(durationMillis = 300, easing = EaseOutCubic) - ) + fadeOut(animationSpec = tween(easing = EaseOut, durationMillis = 300)) + animationSpec = tween(durationMillis = 300, easing = LinearEasing) + ) + fadeOut(animationSpec = tween(easing = FastOutLinearInEasing, durationMillis = 300)) + +val AnimatedContentTransitionScope.slideInFromLeftAndFadeIn: EnterTransition + get() = slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(durationMillis = 300, easing = LinearEasing) + ) + fadeIn(animationSpec = tween(easing = LinearOutSlowInEasing, durationMillis = 300)) +val AnimatedContentTransitionScope.slideOutToRightAndFadeOut: ExitTransition + get() = slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(durationMillis = 300, easing = LinearEasing) + ) + fadeOut(animationSpec = tween(easing = FastOutLinearInEasing, durationMillis = 300)) From 24d88a015c9cfa49c1e1b57b68a1ab40b10ddd3c Mon Sep 17 00:00:00 2001 From: tuuhin Date: Tue, 9 Dec 2025 18:42:27 +0530 Subject: [PATCH 9/9] Updated version names and md files README.md and module_graph.md are corrected Bumped version code and version no to next version --- README.md | 5 ++--- app/build.gradle.kts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 54c743ec..53365ce2 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,7 @@ playing stuff. ## :new: What's new -The latest update to the **RecorderApp** contains a new onboarding screen added with some settings -route improvements +The latest update to the **RecorderApp** contains some improvements with the media player ## :next_track_button: What's next @@ -124,5 +123,5 @@ GitHub. Your feedback is invaluable! ### :end: Conclusion -The app can be marked as finished for now.A significant amount of time and effort has been invested +The app can be marked as finished for now. A significant amount of time and effort has been invested in this project hope you all love it. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6379fdb6..bc010d34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.eva.recorderapp" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.compileSdk.get().toInt() - versionCode = 12 - versionName = "1.4.2" + versionCode = 13 + versionName = "1.4.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {