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 { 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..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,23 +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.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 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,8 +30,9 @@ 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 }, + clipShape: Shape = RectangleShape, ) = composed { val transitionScope = LocalSharedTransitionScopeProvider.current ?: return@composed Modifier val visibilityScope = @@ -47,8 +46,9 @@ fun Modifier.sharedElementWrapper( animatedVisibilityScope = visibilityScope, renderInOverlayDuringTransition = renderInOverlayDuringTransition, zIndexInOverlay = zIndexInOverlay, - placeHolderSize = placeHolderSize, - boundsTransform = boundsTransform + placeholderSize = placeHolderSize, + boundsTransform = boundsTransform, + clipInOverlayDuringTransition = OverlayClip(clipShape) ) } } @@ -58,10 +58,14 @@ 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 }, + clipShape: Shape = RectangleShape, ) = composed { val transitionScope = LocalSharedTransitionScopeProvider.current ?: return@composed Modifier @@ -79,8 +83,38 @@ fun Modifier.sharedBoundsWrapper( boundsTransform = boundsTransform, renderInOverlayDuringTransition = renderInOverlayDuringTransition, zIndexInOverlay = zIndexInOverlay, - placeHolderSize = placeHolderSize, + 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/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)) 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/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/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/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() 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/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/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/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 c7029eaa..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,44 +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(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, @@ -142,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() + ) } } @@ -209,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-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-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..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,7 +26,10 @@ 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 import com.eva.ui.navigation.animatedComposable import com.eva.ui.utils.LocalSharedTransitionVisibilityScopeProvider @@ -92,7 +97,6 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = CompositionLocalProvider(LocalSharedTransitionVisibilityScopeProvider provides this) { AudioPlayerScreen( - audioId = route.audioId, loadState = contentState, bookmarks = bookMarks, waveforms = { visuals }, @@ -105,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)) { @@ -113,6 +117,11 @@ fun NavGraphBuilder.audioPlayerRoute(controller: NavHostController) = controller.navigate(dialog) } }, + onNavigateToRecordings = { + controller.navigate(NavRoutes.VoiceRecordings) { + popUpTo() + } + }, navigation = { if (controller.previousBackStackEntry?.destination?.route != null) { IconButton( @@ -125,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 23310277..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, @@ -81,6 +71,7 @@ internal fun AudioPlayerScreen( navigation: @Composable () -> Unit = {}, onNavigateToEdit: () -> Unit = {}, onRenameItem: (Long) -> Unit = {}, + onNavigateToRecordings: () -> Unit = {}, ) { val snackBarProvider = LocalSnackBarProvider.current @@ -114,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, @@ -142,11 +128,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) ) { @@ -169,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( 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) } } 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..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 @@ -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() + } } } @@ -114,15 +119,14 @@ 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() { // cleanup for controller + _controllerSetUp?.cancel() player.cleanUp() - super.onCleared() } } \ No newline at end of file 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"