From a1ba45ea3081c6c1cbbe77d80c99690ca3adcbff Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 20 Dec 2025 16:12:40 +0530 Subject: [PATCH 01/11] Updated libs.versions.toml and corrected visuals update in editor EditorViewmodel editConfigs were not send to Visualizer viewmodel causing no updates, corrected that Using player mute and unmute apis rather than setting the volume in AudioFilePlayerImpl.kt onMuteDevice AudioInfoExtractor.kt separates the logic for extracting media info in PlayerFileProviderImpl.kt PlayerFileProviderImpl.kt now only handles the audio metadata from media store --- data/player/build.gradle.kts | 1 + .../player/data/AudioMetadataRetrieverImpl.kt | 2 +- .../player/data/player/AudioFilePlayerImpl.kt | 6 +- .../data/provider/AudioInfoExtractorImpl.kt | 67 +++++++++++++++++++ .../data/provider/PlayerFileProviderImpl.kt | 65 ++---------------- .../recordings/di/RecordingsProviderModule.kt | 16 ++++- .../domain/provider/AudioInfoExtractor.kt | 8 +++ .../eva/feature_editor/AudioEditorRoute.kt | 9 +++ gradle/libs.versions.toml | 15 +++-- 9 files changed, 116 insertions(+), 73 deletions(-) create mode 100644 data/recordings/src/main/java/com/eva/recordings/data/provider/AudioInfoExtractorImpl.kt create mode 100644 data/recordings/src/main/java/com/eva/recordings/domain/provider/AudioInfoExtractor.kt diff --git a/data/player/build.gradle.kts b/data/player/build.gradle.kts index f1cac244..c761e30b 100644 --- a/data/player/build.gradle.kts +++ b/data/player/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(libs.androidx.media3.common) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.inspector) // futures to coroutine implementation(libs.androidx.concurrent.futures.ktx) diff --git a/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt b/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt index 6a70dde3..5ddd5800 100644 --- a/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt +++ b/data/player/src/main/java/com/eva/player/data/AudioMetadataRetrieverImpl.kt @@ -4,8 +4,8 @@ import android.content.Context import androidx.concurrent.futures.await import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.MetadataRetriever import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.inspector.MetadataRetriever import com.eva.player.domain.AudioMetadataRetriever import com.eva.recordings.domain.models.AudioFileModel import kotlin.time.Duration diff --git a/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt index b4f81501..1335318e 100644 --- a/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt +++ b/data/player/src/main/java/com/eva/player/data/player/AudioFilePlayerImpl.kt @@ -1,7 +1,9 @@ package com.eva.player.data.player import android.util.Log +import androidx.annotation.OptIn import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import com.eva.player.data.util.computeIsPlayerPlaying import com.eva.player.data.util.computePlayerTrackData import com.eva.player.data.util.toMediaItem @@ -55,14 +57,14 @@ internal class AudioFilePlayerImpl(private val player: Player) : AudioFilePlayer player.repeatMode = repeatMode } + @OptIn(UnstableApi::class) override fun onMuteDevice() { val command = player.isCommandAvailable(Player.COMMAND_SET_VOLUME) if (!command) { Log.w(LOGGER, "PLAYER COMMAND NOT FOUND") return } - val isStreamMuted = player.volume == 0f - player.volume = if (isStreamMuted) 1f else 0f + if (player.volume == .0f) player.unmute() else player.mute() } override suspend fun preparePlayer(audio: AudioFileModel): Result { diff --git a/data/recordings/src/main/java/com/eva/recordings/data/provider/AudioInfoExtractorImpl.kt b/data/recordings/src/main/java/com/eva/recordings/data/provider/AudioInfoExtractorImpl.kt new file mode 100644 index 00000000..89816e7c --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/data/provider/AudioInfoExtractorImpl.kt @@ -0,0 +1,67 @@ +package com.eva.recordings.data.provider + +import android.content.Context +import android.media.MediaExtractor +import android.media.MediaFormat +import android.media.MediaMetadataRetriever +import android.os.Build +import androidx.core.net.toUri +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.domain.models.MediaMetaDataInfo +import com.eva.recordings.domain.provider.AudioInfoExtractor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext + +class AudioInfoExtractorImpl( + private val context: Context, + private val settings: RecorderAudioSettingsRepo, + private val addressProvider: LocationAddressProvider, +) : AudioInfoExtractor { + + override suspend fun extractMediaData(uri: String): MediaMetaDataInfo? { + val extractor = MediaExtractor() + val retriever = MediaMetadataRetriever() + try { + val audioFileUri = uri.toUri() + return withContext(Dispatchers.IO) {// set source + extractor.setDataSource(context, audioFileUri, null) + retriever.setDataSource(context, audioFileUri) + // its accountable that there is a single track + val mediaFormat = extractor.getTrackFormat(0) + val channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + + val sampleRate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE) + ?.toIntOrNull() ?: 0 + } else mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) + + val locationAsString = async { + val audioSettings = settings.audioSettings() + if (!audioSettings.addLocationInfoInRecording) return@async null + val locationString = + retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) + parseLocationFromString(locationString) + ?.let { addressProvider.invoke(it).getOrNull() } + } + val bitRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) + ?.toIntOrNull() ?: mediaFormat.getInteger(MediaFormat.KEY_BIT_RATE) + + MediaMetaDataInfo( + channelCount = channelCount, + sampleRate = sampleRate, + bitRate = bitRate, + locationString = locationAsString.await() + ) + } + } catch (e: Exception) { + e.printStackTrace() + return null + } finally { + retriever.release() + extractor.release() + } + } +} \ No newline at end of file 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 9a31a697..de7bb240 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 @@ -5,31 +5,21 @@ import android.content.ContentUris import android.content.Context import android.database.ContentObserver import android.database.Cursor -import android.media.MediaExtractor -import android.media.MediaFormat -import android.media.MediaMetadataRetriever -import android.net.Uri -import android.os.Build import android.provider.MediaStore import android.util.Log -import androidx.core.net.toUri 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.InvalidAudioFileIdException import com.eva.recordings.domain.models.AudioFileModel -import com.eva.recordings.domain.models.MediaMetaDataInfo +import com.eva.recordings.domain.provider.AudioInfoExtractor import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.recordings.domain.provider.ResourcedDetailedRecordingModel import com.eva.utils.Resource import com.eva.utils.toLocalDateTime import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow @@ -41,9 +31,8 @@ import kotlin.time.Duration.Companion.seconds private const val TAG = "PLAYER_FILE_PROVIDER" internal class PlayerFileProviderImpl( - private val context: Context, - private val settings: RecorderAudioSettingsRepo, - private val addressProvider: LocationAddressProvider, + context: Context, + private val extractor: AudioInfoExtractor, ) : RecordingsContentResolverWrapper(context), PlayerFileProvider { private val _projection: Array @@ -94,7 +83,7 @@ internal class PlayerFileProviderImpl( readTime = BuildConfig.DEBUG ) { if (!readMetaData) return@evaluateWithTimeRead null - extractMediaInfo(model.fileUri.toUri()) + extractor.extractMediaData(model.fileUri) } val modelWithMetaData = model.copy(metaData = metaData) // send data with metadata @@ -159,7 +148,7 @@ internal class PlayerFileProviderImpl( readTime = BuildConfig.DEBUG ) { if (!readMetaData) return@use result - extractMediaInfo(result.fileUri.toUri()) + extractor.extractMediaData(result.fileUri) } result.copy(metaData = metaData) } ?: return@withContext Result.failure(InvalidAudioFileIdException()) @@ -168,50 +157,6 @@ internal class PlayerFileProviderImpl( } - private suspend fun extractMediaInfo(uri: Uri): MediaMetaDataInfo? { - val extractor = MediaExtractor() - val retriever = MediaMetadataRetriever() - try { - return withContext(Dispatchers.IO) {// set source - extractor.setDataSource(context, uri, null) - retriever.setDataSource(context, uri) - // its accountable that there is a single track - val mediaFormat = extractor.getTrackFormat(0) - val channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) - - val sampleRate = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_SAMPLERATE) - ?.toIntOrNull() ?: 0 - } else mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) - - val locationAsString = async { - val audioSettings = settings.audioSettings() - if (!audioSettings.addLocationInfoInRecording) return@async null - val locationString = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) - parseLocationFromString(locationString) - ?.let { addressProvider.invoke(it).getOrNull() } - } - - val bitRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE) - ?.toIntOrNull() ?: 0 - - MediaMetaDataInfo( - channelCount = channelCount, - sampleRate = sampleRate, - bitRate = bitRate, - locationString = locationAsString.await() - ) - } - } catch (e: Exception) { - e.printStackTrace() - return null - } finally { - retriever.release() - extractor.release() - } - } - private suspend fun evaluateValuesFromCursor(cursor: Cursor): AudioFileModel? { return withContext(Dispatchers.IO) { diff --git a/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt b/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt index f246fbbe..a9eead0a 100644 --- a/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt +++ b/data/recordings/src/main/java/com/eva/recordings/di/RecordingsProviderModule.kt @@ -8,6 +8,7 @@ import com.eva.datastore.domain.repository.RecorderAudioSettingsRepo import com.eva.datastore.domain.repository.RecorderFileSettingsRepo import com.eva.location.domain.repository.LocationAddressProvider import com.eva.recordings.data.RecordingWidgetInteractorImpl +import com.eva.recordings.data.provider.AudioInfoExtractorImpl import com.eva.recordings.data.provider.PlayerFileProviderImpl import com.eva.recordings.data.provider.RecorderFileProviderImpl import com.eva.recordings.data.provider.RecordingSecondaryDataProviderImpl @@ -15,6 +16,7 @@ import com.eva.recordings.data.provider.StorageInfoProviderImpl import com.eva.recordings.data.provider.TrashRecordingsProviderApi29Impl import com.eva.recordings.data.provider.TrashRecordingsProviderImpl import com.eva.recordings.data.provider.VoiceRecordingsProviderImpl +import com.eva.recordings.domain.provider.AudioInfoExtractor import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.recordings.domain.provider.RecorderFileProvider import com.eva.recordings.domain.provider.RecordingsSecondaryDataProvider @@ -33,6 +35,15 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object RecordingsProviderModule { + @Provides + @Singleton + fun providesAudioExtractor( + @ApplicationContext context: Context, + locationProvider: LocationAddressProvider, + settings: RecorderAudioSettingsRepo, + ): AudioInfoExtractor = + AudioInfoExtractorImpl(context, settings, locationProvider) + @Provides @Singleton fun providesRecordingsProvider( @@ -66,9 +77,8 @@ object RecordingsProviderModule { @Singleton fun providesPlayerFileProvider( @ApplicationContext context: Context, - locationProvider: LocationAddressProvider, - settings: RecorderAudioSettingsRepo, - ): PlayerFileProvider = PlayerFileProviderImpl(context, settings, locationProvider) + extractor: AudioInfoExtractor, + ): PlayerFileProvider = PlayerFileProviderImpl(context, extractor) @Provides diff --git a/data/recordings/src/main/java/com/eva/recordings/domain/provider/AudioInfoExtractor.kt b/data/recordings/src/main/java/com/eva/recordings/domain/provider/AudioInfoExtractor.kt new file mode 100644 index 00000000..ba8cef6d --- /dev/null +++ b/data/recordings/src/main/java/com/eva/recordings/domain/provider/AudioInfoExtractor.kt @@ -0,0 +1,8 @@ +package com.eva.recordings.domain.provider + +import com.eva.recordings.domain.models.MediaMetaDataInfo + +fun interface AudioInfoExtractor { + + suspend fun extractMediaData(uri: String): MediaMetaDataInfo? +} \ 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 675c7955..520e043c 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 @@ -86,6 +86,15 @@ fun NavGraphBuilder.audioEditorRoute(controller: NavController) = } } + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + editorViewModel.clipConfigs.collectLatest { + // when clip configs are updated the compressed visuals also get updated + visualsViewmodel.updateClipConfigs(it) + } + } + } + CompositionLocalProvider(LocalSharedTransitionVisibilityScopeProvider provides this) { AudioEditorScreen( loadState = loadState, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0e36a6f..685676ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.1" +agp = "8.13.2" concurrentFuturesKtx = "1.3.0" datastore = "1.2.0" coreSplashscreen = "1.2.0" @@ -17,23 +17,23 @@ kotlinxCollectionsImmutable = "0.4.0" kotlinxDatetime = "0.7.1" kotlinxSerializationJson = "1.9.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.1" -composeBom = "2025.12.00" -ksp = "2.3.0" +activityCompose = "1.12.2" +composeBom = "2025.12.01" +ksp = "2.3.4" hilt = "2.57.2" -media3Common = "1.8.0" +media3Common = "1.9.0" navigationCompose = "2.9.6" playServicesLocationVersion = "21.3.0" roomCompiler = "2.8.4" uiTextGoogleFonts = "1.10.0" workRuntimeKtxVersion = "2.11.0" hiltWork = "1.3.0" -protobufJavalite = "4.33.1" +protobufJavalite = "4.33.2" protobuf_version = "0.9.5" protobuf_gen_java_lite = "3.0.0" materialIconExtended = "1.7.8" moduleGrapher = "0.13.0" -mockk = "1.14.6" +mockk = "1.14.7" coroutines = "1.10.2" turbine_version = "1.2.1" compileSdk = "36" @@ -64,6 +64,7 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3Common" } androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3Common" } androidx-media3-effects = { module = "androidx.media3:media3-effect", version.ref = "media3Common" } +androidx-media3-inspector = { module = "androidx.media3:media3-inspector", version.ref = "media3Common" } androidx-media3-extractor = { module = "androidx.media3:media3-extractor", version.ref = "media3Common" } androidx-media3-decoder = { module = "androidx.media3:media3-decoder", version.ref = "media3Common" } androidx-media3-ui = { module = "androidx.media3:media3-ui-compose-material3", version.ref = "media3Common" } From 7a3980a2f569600f73f77b10d020c9a59c6c6533 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 20 Dec 2025 16:39:21 +0530 Subject: [PATCH 02/11] Using the new MediaExtractorCompact to decode audio clip AudioVisualizerImpl.kt primary constructor changed for extractor and datasource factory In MediaCodecPCMDataDecoder.kt using the MediaExtractorCompact the code is kind of same just the api changed MediaFormatExt.kt may not contain duration key add a contains key check return Result.Failure of MediaExtractorException.kt if there is some extractor issues --- .../visualizer/data/AudioVisualizerImpl.kt | 18 ++++- .../data/MediaCodecPCMDataDecoder.kt | 68 ++++++++++++------- .../com/com/visualizer/data/MediaFormatExt.kt | 10 ++- .../ExtractorNoTrackFoundException.kt | 3 - .../exception/MediaExtractorException.kt | 3 + 5 files changed, 70 insertions(+), 32 deletions(-) delete mode 100644 data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt create mode 100644 data/visualizer/src/main/java/com/com/visualizer/domain/exception/MediaExtractorException.kt diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt b/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt index 58152ed6..86b6524d 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 @@ -2,8 +2,14 @@ package com.com.visualizer.data import android.content.Context import android.util.Log +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.lifecycle.LifecycleOwner +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory import com.com.visualizer.domain.AudioVisualizer import com.com.visualizer.domain.ThreadController import com.com.visualizer.domain.VisualizerState @@ -22,11 +28,19 @@ import kotlinx.coroutines.sync.withLock private const val TAG = "PLAIN_VISUALIZER" +@OptIn(UnstableApi::class) internal class AudioVisualizerImpl( - private val context: Context, + private val extractor: ExtractorsFactory, + private val dataSource: DataSource.Factory, private val threadHandler: ThreadController ) : AudioVisualizer { + constructor(context: Context, threadHandler: ThreadController) : this( + extractor = DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true), + dataSource = DefaultDataSource.Factory(context), + threadHandler = threadHandler + ) + private var _decoder: MediaCodecPCMDataDecoder? = null private val _lock = Mutex() @@ -81,7 +95,7 @@ internal class AudioVisualizerImpl( // decoder work is done so we can kill it now if (handler != null) threadHandler.stopThread(handler) } - decoder.initiateExtraction(context, fileUri.toUri()) + decoder.initiateExtraction(extractor, dataSource, fileUri.toUri()) } catch (e: Exception) { Log.e(TAG, "CANNOT DECODE THIS URI", e) Result.failure(e) 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 7ea51f91..d437eb50 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 @@ -1,16 +1,18 @@ package com.com.visualizer.data -import android.content.Context import android.media.MediaCodec import android.media.MediaCodecList -import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log -import com.com.visualizer.domain.exception.ExtractorNoTrackFoundException +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.inspector.MediaExtractorCompat import com.com.visualizer.domain.exception.InvalidMimeTypeException +import com.com.visualizer.domain.exception.MediaExtractorException import com.com.visualizer.utils.isThreadAlive import com.com.visualizer.utils.safePOST import kotlinx.coroutines.CancellationException @@ -39,6 +41,7 @@ private const val CODEC_TAG = "CODEC_CALLBACK" private const val PROCESSING_TAG = "CODEC_PROCESSING" private const val EXTRACTOR_TAG = "MEDIA_EXTRACTOR" +@UnstableApi @OptIn(ExperimentalAtomicApi::class) internal class MediaCodecPCMDataDecoder( private val seekDurationMillis: Int, @@ -51,7 +54,7 @@ internal class MediaCodecPCMDataDecoder( private var _mediaCodec: MediaCodec? = null @Volatile - private var _extractor: MediaExtractor? = null + private var _extractor: MediaExtractorCompat? = null @Volatile private var _codecState = MediaCodecState.RELEASED @@ -111,7 +114,10 @@ internal class MediaCodecPCMDataDecoder( return } // seek the extractor as we don't need extra data - extractor.seekTo(_currentTimeInMs.load() * 1_000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC) + extractor.seekTo( + _currentTimeInMs.load() * 1_000L, + MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC + ) val sampleSize = extractor.readSampleData(inputBuffer, 0) // sample size is zero thus processing done END_OF_STREAM @@ -191,7 +197,10 @@ internal class MediaCodecPCMDataDecoder( } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - Log.d(CODEC_TAG, "MEDIA FORMAT CHANGED: $format") + if (format.keys.contains(MediaFormat.KEY_DURATION)) { + _totalTimeInMs.store(format.duration?.inWholeMilliseconds ?: 0L) + } + Log.d(CODEC_TAG, "MEDIA FORMAT CHANGED: KEYS:${format.keys}") } private fun handleFloatArray(pcm: FloatArray) { @@ -269,27 +278,38 @@ internal class MediaCodecPCMDataDecoder( _onDecodeComplete = listener } - 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 + suspend fun initiateExtraction( + extractor: ExtractorsFactory, + dataSource: DataSource.Factory, + fileURI: Uri + ): Result = withContext(ioDispatcher) { + _extractor?.release() + _extractor = MediaExtractorCompat(extractor, dataSource).apply { + setDataSource(fileURI, 0) + } + // check track count + if (_extractor?.trackCount == 0) + return@withContext Result.failure(MediaExtractorException("No track found")) - if (mimeType == null || !mimeType.startsWith("audio")) - return@withContext Result.failure(InvalidMimeTypeException()) + val format = _extractor!!.getTrackFormat(0) + val mimeType = format.mimeType - if (_extractor?.trackCount == 0) - return@withContext Result.failure(ExtractorNoTrackFoundException()) + // check audio duration + if (!format.keys.contains(MediaFormat.KEY_DURATION)) + return@withContext Result.failure(MediaExtractorException("Cannot determine track duration")) - Log.i(EXTRACTOR_TAG, "EXTRACTOR PREPARED") - _extractor?.selectTrack(0) - _totalTimeInMs.store(format.duration.inWholeMilliseconds) - initiateCodec(format) - Result.success(Unit) - } + // check mime type + if (mimeType == null || !mimeType.startsWith("audio")) + return@withContext Result.failure(InvalidMimeTypeException()) + + // set the total time + _totalTimeInMs.store(format.duration?.inWholeMilliseconds ?: 0L) + + Log.i(EXTRACTOR_TAG, "EXTRACTOR PREPARED") + _extractor?.selectTrack(0) + initiateCodec(format) + Result.success(Unit) + } private fun initiateCodec(format: MediaFormat) { diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt b/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt index 804d243b..1da26dd2 100644 --- a/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt +++ b/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt @@ -24,11 +24,15 @@ internal val MediaFormat.channels: Int internal val MediaFormat.sampleRate: Int get() = getInteger(MediaFormat.KEY_SAMPLE_RATE) -internal val MediaFormat.duration: Duration - get() = getLong(MediaFormat.KEY_DURATION).microseconds +internal val MediaFormat.duration: Duration? + get() = if (containsKey(MediaFormat.KEY_DURATION)) + getLong(MediaFormat.KEY_DURATION).microseconds + else null internal val MediaFormat.mimeType: String? - get() = getString(MediaFormat.KEY_MIME) + get() = if (containsKey(MediaFormat.KEY_MIME)) + getString(MediaFormat.KEY_MIME) + else null internal val MediaCodec.BufferInfo.isEndOfStream: Boolean get() = flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt deleted file mode 100644 index f78f21d7..00000000 --- a/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.com.visualizer.domain.exception - -class ExtractorNoTrackFoundException : Exception("No tracks found in the associated URI") \ No newline at end of file diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/exception/MediaExtractorException.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/MediaExtractorException.kt new file mode 100644 index 00000000..a033bbe2 --- /dev/null +++ b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/MediaExtractorException.kt @@ -0,0 +1,3 @@ +package com.com.visualizer.domain.exception + +class MediaExtractorException(override val message: String) : Exception(message) \ No newline at end of file From a4ad8c33355e471e5d63ce1aecbea9a3ec967017 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 20 Dec 2025 19:12:00 +0530 Subject: [PATCH 03/11] Correction in AudioConfigsToVisuals.kt and PlayerVisualizerViewmodel.kt Some of the deprecated stuff is replaced with new api PlayerVisualizerViewmodel.kt in updating the new visuals we ensure it's not in invalid state Included test for UpdateArrayViaConfigsTest.kt which checks if AudioConfigsToVisuals.kt Included a secondary AudioClipConfig.kt for testing purpose --- .../transformer/AudioFileToComposition.kt | 16 ++- .../transformer/TransformerAwaitResult.kt | 2 +- .../editor/domain/model/AudioClipConfig.kt | 7 + data/visualizer/build.gradle.kts | 1 + feature/player-shared/build.gradle.kts | 2 + .../PlayerVisualizerViewmodel.kt | 13 +- .../util/AudioConfigsToVisuals.kt | 60 ++++---- .../util/UpdateArrayViaConfigsTest.kt | 133 ++++++++++++++++++ 8 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 feature/player-shared/src/test/java/com/eva/player_shared/util/UpdateArrayViaConfigsTest.kt diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt index 04187fa8..dbd91f9f 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/AudioFileToComposition.kt @@ -2,6 +2,7 @@ package com.eva.editor.data.transformer import android.util.Log import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.Effect import androidx.media3.common.MediaItem import androidx.media3.common.audio.AudioProcessor @@ -62,13 +63,16 @@ internal fun AudioFileModel.toComposition( Log.d(TAG, "CLIPPING :${ranges.joinToString("|")}") - val itemSequence = EditedMediaItemSequence.Builder().also { builder -> - editableItems.forEachIndexed { idx, item -> - builder.addItem(item) - if (gap > Duration.ZERO && idx + 1 < editableItems.size) - builder.addGap(gap.inWholeMicroseconds) + val itemSequence = EditedMediaItemSequence + .Builder(setOf(C.TRACK_TYPE_AUDIO)) + .also { builder -> + editableItems.forEachIndexed { idx, item -> + builder.addItem(item) + if (gap > Duration.ZERO && idx + 1 < editableItems.size) + builder.addGap(gap.inWholeMicroseconds) + } } - }.build() + .build() return Composition.Builder(itemSequence) .build() diff --git a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt index fc3809b3..35db582e 100644 --- a/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt +++ b/data/editor/src/main/java/com/eva/editor/data/transformer/TransformerAwaitResult.kt @@ -83,7 +83,7 @@ internal suspend fun Transformer.awaitResults( @UnstableApi suspend fun Transformer.awaitResults(mediaItem: MediaItem, outputUri: String): String { val editMediaItem = EditedMediaItem.Builder(mediaItem).build() - val sequence = EditedMediaItemSequence.Builder(editMediaItem).build() + val sequence = EditedMediaItemSequence.withAudioFrom(listOf(editMediaItem)) val composition = Composition.Builder(sequence).build() return awaitResults(composition, outputUri) } \ No newline at end of file diff --git a/data/editor/src/main/java/com/eva/editor/domain/model/AudioClipConfig.kt b/data/editor/src/main/java/com/eva/editor/domain/model/AudioClipConfig.kt index cecfc8c0..e1644954 100644 --- a/data/editor/src/main/java/com/eva/editor/domain/model/AudioClipConfig.kt +++ b/data/editor/src/main/java/com/eva/editor/domain/model/AudioClipConfig.kt @@ -1,12 +1,19 @@ package com.eva.editor.domain.model import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds data class AudioClipConfig( val start: Duration = 0.seconds, val end: Duration = 1.seconds, ) { + + constructor( + startMillis: Int, + endMillis: Int + ) : this(startMillis.milliseconds, endMillis.milliseconds) + fun validate(totalDuration: Duration): Boolean { return hasMinimumDuration && start.isPositive() && end <= totalDuration } diff --git a/data/visualizer/build.gradle.kts b/data/visualizer/build.gradle.kts index 991d3701..4be8ce50 100644 --- a/data/visualizer/build.gradle.kts +++ b/data/visualizer/build.gradle.kts @@ -8,5 +8,6 @@ android { } dependencies { + implementation(libs.androidx.media3.inspector) implementation(project(":data:recordings")) } \ No newline at end of file diff --git a/feature/player-shared/build.gradle.kts b/feature/player-shared/build.gradle.kts index ff9af596..0518aab6 100644 --- a/feature/player-shared/build.gradle.kts +++ b/feature/player-shared/build.gradle.kts @@ -27,4 +27,6 @@ dependencies { implementation(project(":data:recordings")) implementation(project(":data:interactions")) implementation(project(":data:use_case")) + + testImplementation(libs.mockk) } \ No newline at end of file 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 4bd8f361..3a959da4 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 @@ -111,15 +111,20 @@ class PlayerVisualizerViewmodel @Inject constructor( } - private fun updatesOnConfigChange() { - combine(visualizer.normalizedVisualization, _clipConfigs) { visuals, configs -> + private fun updatesOnConfigChange() = combine( + visualizer.normalizedVisualization, + _clipConfigs + ) { visuals, configs -> + try { val newVisuals = visuals.updateArrayViaConfigs( configs = configs, timeInMillisPerBar = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE ) _compressedVisualization.update { newVisuals } - }.launchIn(viewModelScope) - } + } catch (_: IllegalStateException) { + _uiEvents.emit(UIEvents.ShowSnackBar("Cannot update visuals")) + } + }.launchIn(viewModelScope) override fun onCleared() { visualizer.cleanUp() diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/util/AudioConfigsToVisuals.kt b/feature/player-shared/src/main/java/com/eva/player_shared/util/AudioConfigsToVisuals.kt index 9f2568f8..40cf6818 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/util/AudioConfigsToVisuals.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/util/AudioConfigsToVisuals.kt @@ -5,6 +5,7 @@ import com.eva.editor.domain.AudioConfigToActionList import com.eva.editor.domain.model.AudioEditAction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext import kotlin.math.max import kotlin.math.min @@ -12,47 +13,50 @@ private const val TAG = "PLAYER_CONFIG_SETTER" internal suspend fun FloatArray.updateArrayViaConfigs( configs: AudioConfigToActionList, - timeInMillisPerBar: Int = 100 + timeInMillisPerBar: Int = 100, + dispatcher: CoroutineContext = Dispatchers.Default ): FloatArray { - return withContext(Dispatchers.Default) { + require(timeInMillisPerBar > 0) { "timeInMillisPerBar must be positive" } + return withContext(dispatcher) { if (configs.isEmpty()) return@withContext this@updateArrayViaConfigs var modifiedArray = copyOf() - Log.d(TAG, "INITIAL SIZE :${size}") - // need to apply the config from back to front - configs.reversed().forEachIndexed { iteration, (config, action) -> - val startSample = (config.start.inWholeMilliseconds / timeInMillisPerBar).toInt() - val endSample = (config.end.inWholeMilliseconds / timeInMillisPerBar).toInt() - - val validStart = max(0, min(startSample, modifiedArray.size)) - val validEnd = max(0, min(endSample, modifiedArray.size)) + Log.d(TAG, "=========================================================") + configs.forEachIndexed { iteration, (config, action) -> + val startIndex = (config.start.inWholeMilliseconds / timeInMillisPerBar).toInt() + val endIndex = (config.end.inWholeMilliseconds / timeInMillisPerBar).toInt() - if (validStart <= validEnd) { + val validStart = max(0, min(startIndex, modifiedArray.size)) + val validEnd = max(0, min(endIndex, modifiedArray.size)) - val message = when (action) { - AudioEditAction.CROP -> "NEW START :${validStart} NEW END:$validEnd" - AudioEditAction.CUT -> "START1 :0 END1:$validStart || START2 :$validEnd END2: ${modifiedArray.size}" - } + if (validStart >= validEnd) { + val error = "INVALID RANGE: [$validStart:$validEnd] ACT:$action I_TER:$iteration" + Log.e(TAG, error) + throw IllegalStateException(error) + } - Log.i(TAG, "ITERATION:$iteration $message") + Log.d(TAG, "ITERATION:$iteration") - modifiedArray = when (action) { - AudioEditAction.CUT -> { - val before = modifiedArray.copyOfRange(0, validStart) - val after = modifiedArray.copyOfRange(validEnd, modifiedArray.size) - before + after - } + when (action) { + AudioEditAction.CUT -> { + Log.d(TAG, "ACTION:CUT [START :${validStart} END:$validEnd]") + val before = modifiedArray.copyOfRange(0, validStart) + val after = modifiedArray.copyOfRange(validEnd, modifiedArray.size) + modifiedArray = before + after + } - AudioEditAction.CROP -> - modifiedArray.copyOfRange(validStart, validEnd) + AudioEditAction.CROP -> { + Log.d( + TAG, + "ACTION:CROP [START :0 END:$validStart] ---xx-- [START:$validEnd END: ${modifiedArray.size}]" + ) + modifiedArray = modifiedArray.copyOfRange(validStart, validEnd) } - } else { - val error = "Invalid clip: $validStart, $validEnd. $action at index $iteration" - Log.w(TAG, error) } } - Log.d(TAG, "Final array size after processing: ${modifiedArray.size}") + Log.d(TAG, "=========================================================") + Log.d(TAG, "FINAL ARRAY SIZE AFTER PROCESSING: ${modifiedArray.size}") modifiedArray } } \ No newline at end of file diff --git a/feature/player-shared/src/test/java/com/eva/player_shared/util/UpdateArrayViaConfigsTest.kt b/feature/player-shared/src/test/java/com/eva/player_shared/util/UpdateArrayViaConfigsTest.kt new file mode 100644 index 00000000..8abb53c9 --- /dev/null +++ b/feature/player-shared/src/test/java/com/eva/player_shared/util/UpdateArrayViaConfigsTest.kt @@ -0,0 +1,133 @@ +package com.eva.player_shared.util + +import android.util.Log +import com.eva.editor.domain.model.AudioClipConfig +import com.eva.editor.domain.model.AudioEditAction +import io.mockk.every +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +class UpdateArrayViaConfigsTest { + + private val timePerBlock = 100 + + @BeforeTest + fun setup() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + } + + @Test + fun `cut removes the middle section`() = runTest { + val original = FloatArray(10) + val configs = listOf( + AudioClipConfig(2 * timePerBlock, 5 * timePerBlock) to AudioEditAction.CUT + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + val expectedSize = FloatArray(7) + assertArrayEquals(expectedSize, result, 0.0f) + } + + @Test + fun `crop removes the boundary`() = runTest { + val original = FloatArray(10) + val configs = listOf( + AudioClipConfig(2 * timePerBlock, 5 * timePerBlock) to AudioEditAction.CROP + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + val expected = FloatArray(3) + assertArrayEquals(expected, result, 0.0f) + } + + @Test(expected = IllegalStateException::class) + fun `range is reveres should throw an exception`() = runTest { + val original = FloatArray(3) + val configs = listOf( + AudioClipConfig(5 * timePerBlock, 2 * timePerBlock) to AudioEditAction.CUT + ) + original.updateArrayViaConfigs(configs, timePerBlock) + } + + @Test + fun `empty configs don't change the array content`() = runTest { + val original = FloatArray(10) + val configs = emptyList>() + + val result = original.updateArrayViaConfigs(configs, timePerBlock) + assertArrayEquals(original, result, 0.0f) + } + + @Test + fun `cut then cut then crop`() = runTest { + val original = FloatArray(10) + val configs = listOf( + AudioClipConfig(timePerBlock, 3 * timePerBlock) to AudioEditAction.CUT, + AudioClipConfig(5 * timePerBlock, 7 * timePerBlock) to AudioEditAction.CUT, + AudioClipConfig(timePerBlock, 4 * timePerBlock) to AudioEditAction.CROP + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + + val expected = FloatArray(3) + assertArrayEquals(expected, result, 0.0f) + } + + @Test + fun `cut from front and cut from back`() = runTest { + val original = FloatArray(10) { it.toFloat() } + + val configs = listOf( + AudioClipConfig(7 * timePerBlock, 10 * timePerBlock) to AudioEditAction.CUT, + AudioClipConfig(0, 3 * timePerBlock) to AudioEditAction.CUT + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + val expected = floatArrayOf(3f, 4f, 5f, 6f) + assertArrayEquals(expected, result, 0.0f) + } + + @Test + fun `cropping the array two times`() = runTest { + val original = FloatArray(10) { (it * 10).toFloat() } + + val configs = listOf( + AudioClipConfig(2 * timePerBlock, 8 * timePerBlock) to AudioEditAction.CROP, + AudioClipConfig(timePerBlock, 4 * timePerBlock) to AudioEditAction.CROP + ) + + val result = original.updateArrayViaConfigs(configs, timePerBlock) + + val expected = floatArrayOf(30f, 40f, 50f) + assertArrayEquals(expected, result, 0.0f) + } + + @Test + fun `combine a mix of cut and crop 1`() = runTest { + val original = floatArrayOf(1f, 2f, 3f, 4f, 5f) + + val configs = listOf( + AudioClipConfig(0, 3 * timePerBlock) to AudioEditAction.CUT, + AudioClipConfig(0, timePerBlock) to AudioEditAction.CROP, + AudioClipConfig(0, timePerBlock) to AudioEditAction.CUT + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + assertEquals(0, result.size) + } + + @Test + fun `combine a mix of cut and crop 2`() = runTest { + val original = FloatArray(10) { it.toFloat() } + + val configs = listOf( + AudioClipConfig(3 * timePerBlock, 7 * timePerBlock) to AudioEditAction.CUT, + AudioClipConfig(2 * timePerBlock, 4 * timePerBlock) to AudioEditAction.CROP, + ) + val result = original.updateArrayViaConfigs(configs, timePerBlock) + val expected = floatArrayOf(2f, 7f) + assertArrayEquals(expected, result, 0f) + } +} \ No newline at end of file From 0a86e67de20a509c29cb577c0d55a3916cd6da04 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 20 Dec 2025 19:15:13 +0530 Subject: [PATCH 04/11] Included a dialog in Editor screen and updated stability_config.conf The exit dialog ensures we don't mistakenly dismiss while editing the clip stability_config.conf updated to hold domain level classes of editor --- core/ui/src/main/res/values-bn/strings.xml | 3 + core/ui/src/main/res/values-hi/strings.xml | 3 + core/ui/src/main/res/values/strings.xml | 3 + .../eva/feature_editor/AudioEditorRoute.kt | 5 ++ .../eva/feature_editor/AudioEditorScreen.kt | 19 +++++ .../ExitEditorScreenConfirmDialog.kt | 72 +++++++++++++++++++ .../feature_editor/undoredo/UndoRedoState.kt | 9 ++- stability_config.conf | 1 + 8 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 feature/editor/src/main/java/com/eva/feature_editor/composables/ExitEditorScreenConfirmDialog.kt diff --git a/core/ui/src/main/res/values-bn/strings.xml b/core/ui/src/main/res/values-bn/strings.xml index faada8bf..2ca5cee6 100644 --- a/core/ui/src/main/res/values-bn/strings.xml +++ b/core/ui/src/main/res/values-bn/strings.xml @@ -236,4 +236,7 @@ শুরু করুন %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 35daefc7..74faafb5 100644 --- a/core/ui/src/main/res/values-hi/strings.xml +++ b/core/ui/src/main/res/values-hi/strings.xml @@ -236,4 +236,7 @@ जारी रखें %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 d58ee03e..7c31ebd4 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -269,4 +269,7 @@ Continue Welcome to %s Go back to List + Exit + Cancel Edit + Exiting the editor screen will cancel your ongoing editing \ 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 520e043c..771ca45d 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 @@ -107,6 +107,11 @@ fun NavGraphBuilder.audioEditorRoute(controller: NavController) = isMediaEdited = isMediaEdited, undoRedoState = undoRedoState, transformationState = transformationState, + onDismissScreen = { + if (controller.previousBackStackEntry != null) { + controller.popBackStack() + } + }, navigation = { if (controller.previousBackStackEntry != null) { IconButton(onClick = dropUnlessResumed(block = controller::popBackStack)) { 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 7a3d0826..5c9e8448 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,5 +1,6 @@ package com.eva.feature_editor +import androidx.activity.compose.BackHandler import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +25,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,9 +33,11 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import com.eva.editor.domain.model.AudioClipConfig import com.eva.feature_editor.composables.AudioClipChipRow import com.eva.feature_editor.composables.EditorActionsAndControls +import com.eva.feature_editor.composables.EditorBackHandlerDialog import com.eva.feature_editor.composables.EditorTopBar import com.eva.feature_editor.composables.PlayerTrimSelector import com.eva.feature_editor.composables.TransformBottomSheet @@ -76,10 +80,16 @@ internal fun AudioEditorScreen( undoRedoState: UndoRedoState = UndoRedoState(), transformationState: TransformationState = TransformationState(), navigation: @Composable () -> Unit = {}, + onDismissScreen: () -> Unit = {}, ) { val snackBarHostProvider = LocalSnackBarProvider.current val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val isBackHandlerEnabled by rememberSaveable(undoRedoState.holdHistory) { + mutableStateOf(undoRedoState.holdHistory) + } + var showDialog by remember { mutableStateOf(false) } + var showSheet by remember { mutableStateOf(false) } val bottomSheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() @@ -97,6 +107,15 @@ internal fun AudioEditorScreen( showSheet = showSheet ) + BackHandler(enabled = isBackHandlerEnabled, onBack = { showDialog = true }) + + EditorBackHandlerDialog( + showDialog = showDialog, + onConfirm = onDismissScreen, + onDismiss = { showDialog = false }, + properties = DialogProperties(dismissOnClickOutside = false) + ) + Scaffold( topBar = { EditorTopBar( diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/ExitEditorScreenConfirmDialog.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/ExitEditorScreenConfirmDialog.kt new file mode 100644 index 00000000..7f0767ba --- /dev/null +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/ExitEditorScreenConfirmDialog.kt @@ -0,0 +1,72 @@ +package com.eva.feature_editor.composables + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.eva.ui.R +import com.eva.ui.theme.RecorderAppTheme + +@Composable +fun EditorBackHandlerDialog( + showDialog: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + properties: DialogProperties = DialogProperties() +) { + val lifeCycleOwner = LocalLifecycleOwner.current + val lifecycleState by lifeCycleOwner.lifecycle.currentStateFlow.collectAsState() + + if (!showDialog) return + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button( + onClick = onConfirm, + enabled = lifecycleState.isAtLeast(Lifecycle.State.RESUMED), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text( + text = stringResource(R.string.action_exit), + fontWeight = FontWeight.SemiBold + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = lifecycleState.isAtLeast(Lifecycle.State.RESUMED), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary) + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + title = { Text(text = stringResource(R.string.editor_screen_exiting_warning_dialog_title)) }, + text = { Text(text = stringResource(R.string.editor_screen_exiting_warning_dialog_desc)) }, + modifier = modifier, + properties = properties, + ) +} + +@PreviewLightDark +@Composable +private fun EditorBackHandlerDialogPreview() = RecorderAppTheme { + EditorBackHandlerDialog(showDialog = true, onConfirm = {}, onDismiss = {}) +} \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/undoredo/UndoRedoState.kt b/feature/editor/src/main/java/com/eva/feature_editor/undoredo/UndoRedoState.kt index ff86ccb1..cc979a42 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/undoredo/UndoRedoState.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/undoredo/UndoRedoState.kt @@ -1,3 +1,10 @@ package com.eva.feature_editor.undoredo -data class UndoRedoState(val canUndo: Boolean = false, val canRedo: Boolean = false) +data class UndoRedoState( + val canUndo: Boolean = false, + val canRedo: Boolean = false +) { + + val holdHistory: Boolean + get() = canUndo || canRedo +} diff --git a/stability_config.conf b/stability_config.conf index cdea9567..430e9437 100644 --- a/stability_config.conf +++ b/stability_config.conf @@ -7,6 +7,7 @@ com.eva.bookmarks.domain.AudioBookmarkModel com.eva.categories.domain.models.* com.eva.datastore.domain.models.* com.eva.datastore.domain.enums.* +com.eva.editor.domain.model.* com.eva.interactions.domain.enums.* com.eva.location.domain.BaseLocationModel com.eva.player.domain.model.* From 14db15bb610432d8dc4c1979d88277320ac8a624 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 26 Dec 2025 20:06:37 +0530 Subject: [PATCH 05/11] Creating a view based version of the graph Compose Ui runs on main thread But this view based approach using a texture view can run on a custom thread thus reducing the pressure of rendering on the main thread SO much smooth ui --- .../composable/PlayerAmplitudeGraph2.kt | 149 ++++++++ .../views/PlayerAmplitudeGraph2View.kt | 334 ++++++++++++++++++ .../feature_player/views/ViewExtensions.kt | 319 +++++++++++++++++ 3 files changed, 802 insertions(+) create mode 100644 feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt create mode 100644 feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt create mode 100644 feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt new file mode 100644 index 00000000..7b48786d --- /dev/null +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt @@ -0,0 +1,149 @@ +package com.eva.feature_player.composable + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontSynthesis +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.resolveAsTypeface +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.eva.feature_player.views.PlayerAmplitudeGraph2View +import com.eva.player.domain.model.PlayerTrackData +import com.eva.player_shared.util.PlayRatio +import com.eva.player_shared.util.PlayerGraphData +import com.eva.ui.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.datetime.LocalTime +import kotlin.time.Duration + +@Composable +fun PlayerAmplitudeGraph2( + totalTrackDuration: Duration, + trackPlayRatio: PlayRatio, + graphData: PlayerGraphData, + modifier: Modifier = Modifier, + bookMarkTimeStamps: ImmutableList = persistentListOf(), + plotColor: Color = MaterialTheme.colorScheme.secondary, + trackPointerColor: Color = MaterialTheme.colorScheme.primary, + bookMarkColor: Color = MaterialTheme.colorScheme.tertiary, + timelineColor: Color = MaterialTheme.colorScheme.outline, + timelineColorVariant: Color = MaterialTheme.colorScheme.outlineVariant, + timelineTextStyle: TextStyle = MaterialTheme.typography.labelSmall, + timelineTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentPadding: PaddingValues = PaddingValues(20.dp), +) { + val resolver = LocalFontFamilyResolver.current + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + + val typeFace by remember(timelineTextStyle) { + resolver.resolveAsTypeface( + fontFamily = timelineTextStyle.fontFamily ?: FontFamily.Monospace, + fontWeight = timelineTextStyle.fontWeight ?: FontWeight.Normal, + fontStyle = timelineTextStyle.fontStyle ?: FontStyle.Normal, + fontSynthesis = timelineTextStyle.fontSynthesis ?: FontSynthesis.None + ) + } + + AndroidView( + factory = { ctx -> + PlayerAmplitudeGraph2View(ctx).also { view -> + // colors and font + view.plotColor = plotColor.toArgb() + view.timelineColor = timelineColor.toArgb() + view.timelineColorVariant = timelineColorVariant.toArgb() + view.timelineTextColor = timelineTextColor.toArgb() + view.bookMarkColor = bookMarkColor.toArgb() + view.trackPointerColor = trackPointerColor.toArgb() + view.canvasBackground = containerColor.toArgb() + // text config + view.textTypeface = typeFace + with(density) { + view.textFontSizeAsPx = timelineTextStyle.fontSize.toPx() + view.setPadding( + contentPadding.calculateLeftPadding(layoutDirection).roundToPx(), + contentPadding.calculateTopPadding().roundToPx(), + contentPadding.calculateRightPadding(layoutDirection).roundToPx(), + contentPadding.calculateBottomPadding().roundToPx(), + ) + } + } + }, + update = { view -> + view.onUpdateTrackDuration(totalTrackDuration) + view.onUpdateBookMarks(bookMarkTimeStamps) + view.onUpdatePlotColor(plotColor.toArgb()) + view.onUpdatePlayRatio { trackPlayRatio() } + view.onUpdateGraphData { graphData() } + }, + onReset = { view -> view.cleanUp() }, + onRelease = { view -> view.cleanUp() }, + modifier = modifier.defaultMinSize(minHeight = dimensionResource(id = R.dimen.line_graph_min_height)) + ) +} + +@Composable +internal fun PlayerAmplitudeGraph2( + trackData: () -> PlayerTrackData, + graphData: PlayerGraphData, + modifier: Modifier = Modifier, + bookMarksTimeStamps: ImmutableList = persistentListOf(), + plotColor: Color = MaterialTheme.colorScheme.secondary, + trackPointerColor: Color = MaterialTheme.colorScheme.primary, + bookMarkColor: Color = MaterialTheme.colorScheme.tertiary, + timelineColor: Color = MaterialTheme.colorScheme.outline, + timelineColorVariant: Color = MaterialTheme.colorScheme.outlineVariant, + timelineTextStyle: TextStyle = MaterialTheme.typography.labelSmall, + timelineTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, + timelineFontFamily: FontFamily = FontFamily.Monospace, + shape: Shape = MaterialTheme.shapes.small, + contentPadding: PaddingValues = PaddingValues( + horizontal = dimensionResource(id = R.dimen.graph_card_padding), + vertical = dimensionResource(id = R.dimen.graph_card_padding_other) + ), +) { + val totalDuration by remember { derivedStateOf { trackData().total } } + + Surface( + shape = shape, + color = containerColor, + modifier = modifier.aspectRatio(1.6f) + ) { + PlayerAmplitudeGraph2( + trackPlayRatio = { trackData().playRatio }, + totalTrackDuration = totalDuration, + graphData = graphData, + bookMarkTimeStamps = bookMarksTimeStamps, + plotColor = plotColor, + trackPointerColor = trackPointerColor, + bookMarkColor = bookMarkColor, + timelineColor = timelineColor, + timelineColorVariant = timelineColorVariant, + timelineTextColor = timelineTextColor, + timelineTextStyle = timelineTextStyle.copy(fontFamily = timelineFontFamily), + contentPadding = contentPadding, + containerColor = containerColor, + ) + } +} diff --git a/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt new file mode 100644 index 00000000..e6b3875f --- /dev/null +++ b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt @@ -0,0 +1,334 @@ +package com.eva.feature_player.views + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.SurfaceTexture +import android.graphics.Typeface +import android.os.SystemClock +import android.util.Log +import android.util.TypedValue +import android.view.TextureView +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withClip +import androidx.core.graphics.withTranslation +import com.eva.ui.R +import com.eva.utils.RecorderConstants +import kotlinx.datetime.LocalTime +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.math.roundToInt +import kotlin.time.Duration + +private const val TAG = "PLAYER_AMPLITUDE_GRAPH_2" + +@OptIn(ExperimentalAtomicApi::class) +internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context), + TextureView.SurfaceTextureListener { + + @Volatile + private var _renderThread: Thread? = null + + @Volatile + private var _isThRunning = false + + @Volatile + private var _isDataAvailable = false + + // Cache for timeline + private var _timelineCacheBitmap: Bitmap? = null + private var _timelineCacheCanvas: Canvas? = null + private val _timelineCached = AtomicBoolean(false) + + // Cache for graph data + private var _graphCacheBitmap: Bitmap? = null + private var _graphCacheCanvas: Canvas? = null + + @Volatile + private var _cachedGraphDataSize = 0 + + // core draw components + @Volatile + private var _graphData: FloatArray = floatArrayOf() + private var _totalTrackDurationMillis: Long = 0L + private var _playRatio: Float = 0f + private val _bookMarkTimeStamps = mutableSetOf() + + // colors and font + var plotColor: Int = Color.WHITE + var bookMarkColor: Int = Color.WHITE + var timelineColor: Int = Color.WHITE + var timelineColorVariant: Int = Color.WHITE + var timelineTextColor: Int = Color.WHITE + var trackPointerColor: Int = Color.WHITE + var canvasBackground: Int = Color.BLACK + var textTypeface: Typeface = Typeface.MONOSPACE + var textFontSizeAsPx: Float = 12f + + private val _bookMarkDrawable by lazy { + ResourcesCompat.getDrawable(resources, R.drawable.ic_bookmark, null) + } + + init { + surfaceTextureListener = this + isOpaque = true + } + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + _renderThread = Thread(renderLoop(), "RENDERER_THREAD_") + _renderThread?.start() + Log.i(TAG, "RENDERER THREAD IS ACTIVE") + _isThRunning = true + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + try { + _isThRunning = false + _renderThread?.join(1000) + } catch (e: Exception) { + Log.e(TAG, "THREAD CLEANUP", e) + } + Log.i(TAG, "RENDERER THREAD IS CLEANED! ${_renderThread?.state}") + return true + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + if (width <= 0 || height <= 0) return + initiateCacheBitmaps(width, height) + Log.i(TAG, "SURFACE TEXTURE SIZE CHANGED") + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { + Log.i(TAG, "SURFACE TEXTURE UPDATED") + } + + private fun initiateCacheBitmaps(width: Int, height: Int) { + // recycle and clear the old bitmap + _timelineCacheBitmap?.recycle() + _graphCacheBitmap?.recycle() + _timelineCacheBitmap = null + _graphCacheBitmap = null + + val maxSamples = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE + val spikesWidth = width.toFloat() / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE + val spikeSpace = dpToPx(2f) + val blockWidth = spikesWidth + spikeSpace + + val probableSampleSize = maxOf(_totalTrackDurationMillis / maxSamples, 10L) + val maxWidth = (probableSampleSize * blockWidth + paddingLeft).roundToInt() + .coerceAtLeast(width * 2) + + // build a timeline based on the probable size + _timelineCacheBitmap = createBitmap(maxWidth, height) + _timelineCacheCanvas = Canvas(_timelineCacheBitmap!!) + _timelineCached.store(false) + + // build an arbitrary graph cache based on the probable size + _graphCacheBitmap = createBitmap(maxWidth, height) + _graphCacheCanvas = Canvas(_graphCacheBitmap!!) + _cachedGraphDataSize = 0 + + Log.d(TAG, "CREATING TIMELINE AND GRAPH OF SIZE: ${maxWidth}x${height}") + } + + private fun renderLoop(frameTimeMs: Long = 33L) = Runnable { + while (_isThRunning) { + val start = SystemClock.uptimeMillis() + + if (surfaceTexture?.isReleased == true) break + // loop over the given canvas + if (_isDataAvailable) { + val canvas = lockCanvas() ?: continue + try { + canvas.drawFrame() + } finally { + _isDataAvailable = false + unlockCanvasAndPost(canvas) + } + } + + val elapsed = SystemClock.uptimeMillis() - start + val sleep = frameTimeMs - elapsed + if (sleep <= 0) continue + try { + Thread.sleep(sleep) + } catch (_: InterruptedException) { + break + } + } + } + + private fun Canvas.drawFrame() { + if (width <= 0 || height <= 0) return + + drawColor(canvasBackground) + + val spikesWidth = width.toFloat() / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE + val spikeSpace = dpToPx(2f) + + val probableSampleSize = maxOf( + _totalTrackDurationMillis / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE, + 100L + ) + + val sampleSize = maxOf(_graphData.size.toLong(), probableSampleSize) + val totalSize = sampleSize * spikesWidth + val translate = (width * 0.5f - paddingLeft) - (totalSize * _playRatio) + + // one time operation + if (!_timelineCached.load() && _timelineCacheCanvas != null) { + Log.d(TAG, "PREPARING TIME LINE") + _timelineCacheCanvas?.drawTimeLineWithBookMarks( + totalDurationInMillis = _totalTrackDurationMillis, + bookMarks = _bookMarkTimeStamps, + bookMarkDrawable = _bookMarkDrawable, + outlineColor = timelineColor, + outlineVariant = timelineColorVariant, + textColor = timelineTextColor, + bookMarkColor = bookMarkColor, + typeface = textTypeface, + spikesWidth = spikesWidth, + imageSize = dpToPx(14f), + textSizeInSp = textFontSizeAsPx, + dpToPx = ::dpToPx, + topPadding = paddingTop, + bottomPadding = paddingBottom, + leftPadding = paddingLeft + ) + _timelineCached.store(true) + Log.d(TAG, "ONE TIME TIMELINE DRAW DONE!!") + } + + // Draw cached timeline + + withTranslation(x = translate) { + _timelineCacheBitmap?.let { bitmap -> + if (bitmap.isRecycled) return@withTranslation + drawBitmap(bitmap, 0f, 0f, null) + } + } + + val currentDataSize = _graphData.size + val cachedSize = _cachedGraphDataSize + if (currentDataSize > cachedSize) { + Log.d(TAG, "DRAWING FROM $cachedSize TO $currentDataSize") + + // Extract only the new data + val newData = _graphData.sliceArray(cachedSize.. + if (bitmap.isRecycled) return@withTranslation + drawBitmap(bitmap, 0f, 0f, null) + } + } + } + + // Draw track pointer + drawTrackPointer( + xAxis = width * .5f, + color = trackPointerColor, + radius = spikesWidth, + strokeWidth = spikesWidth, + topPadding = paddingTop, + bottomPadding = paddingBottom + ) + } + + private fun dpToPx(dp: Float): Float = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, resources.displayMetrics) + + private fun invalidateTimeline() { + _timelineCached.store(false) + _isDataAvailable = true + Log.d(TAG, "TIMELINE CACHE INVALIDATED") + } + + private fun resetGraphCache() { + _graphCacheCanvas?.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + _cachedGraphDataSize = 0 + _isDataAvailable = true + Log.d(TAG, "GRAPH RESET") + } + + fun onUpdateTrackDuration(duration: Duration) { + val millis = duration.inWholeMilliseconds + if (millis == _totalTrackDurationMillis) return + _timelineCached.store(false) + _totalTrackDurationMillis = millis + + // invalidate the timeline cache + invalidateTimeline() + Log.d(TAG, "TRACK DURATION UPDATED") + + if (width > 0 && height > 0) initiateCacheBitmaps(width, height) + } + + fun onUpdateBookMarks(bookMarks: List) { + val oldSize = _bookMarkTimeStamps.size + // reset the bookmarks + _bookMarkTimeStamps.clear() + val sortedList = bookMarks.map { it.toMillisecondOfDay() }.sorted() + _bookMarkTimeStamps.addAll(sortedList) + + // Only invalidate if bookmarks actually changed redraw the timeline + if (_bookMarkTimeStamps.size != oldSize) { + invalidateTimeline() + _isDataAvailable = true + Log.d(TAG, "BOOKMARKS SIZE CHANGED") + } + } + + fun onUpdateGraphData(array: () -> FloatArray) { + _isDataAvailable = true + _graphData = array() + } + + fun onUpdatePlotColor(color: Int) { + if (plotColor == color) return + plotColor = color + resetGraphCache() + Log.d(TAG, "PLOT COLOR UPDATED") + } + + fun onUpdatePlayRatio(ratio: () -> Float) { + _playRatio = ratio() + } + + fun cleanUp() { + if (_renderThread?.state != Thread.State.TERMINATED) { + _renderThread?.join() + } + _renderThread = null + // clean the bitmaps + _timelineCacheBitmap?.recycle() + _timelineCacheBitmap = null + _graphCacheBitmap?.recycle() + _graphCacheBitmap = null + Log.i(TAG, "CLEANUP CODE CALLED") + } +} \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt b/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt new file mode 100644 index 00000000..d4eb85c5 --- /dev/null +++ b/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt @@ -0,0 +1,319 @@ +package com.eva.feature_player.views + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.text.TextPaint +import androidx.core.graphics.withTranslation +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +fun Canvas.drawGraph( + waves: FloatArray, + spikesGap: Float = 2f, + spikesWidth: Float = 2f, + color: Int, + drawPoints: Boolean = true, + startIdx: Int = 0, + topPadding: Int = 0, + bottomPadding: Int = 0, + leftPadding: Int = 0, +) { + val centerYAxis = (height - topPadding - bottomPadding) * 0.5f + val paint = Paint().apply { + this.color = color + strokeWidth = spikesGap + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + for ((idx, value) in waves.withIndex()) { + val actualIndex = startIdx + idx + val sizeFactor = value * .8f + val xAxis = leftPadding + spikesWidth * actualIndex + val startY = centerYAxis * (1 - sizeFactor) + val endY = centerYAxis * (1 + sizeFactor) + + if (startY != endY) { + drawLine(xAxis, topPadding + startY, xAxis, topPadding + endY, paint) + } else if (drawPoints) { + drawCircle(xAxis, topPadding + centerYAxis, spikesGap / 2f, paint) + } + } +} + +fun Canvas.drawGraphCompressed( + waves: FloatArray, + color: Int, + drawPoints: Boolean = true, + spikesWidth: Float = 2f, + viewWidth: Int, + viewHeight: Int +) { + val centerYAxis = viewHeight / 2f + val wavesSize = waves.size + + if (wavesSize == 0) return + + val spacing = viewWidth.toFloat() / (wavesSize + 1) + val pointsList = mutableListOf() + + val paint = Paint().apply { + this.color = color + strokeWidth = spikesWidth + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + waves.forEachIndexed { index, wave -> + val xAxis = (index + 1) * spacing + val sizeFactor = wave * 0.8f + val startY = centerYAxis * (1 - sizeFactor) + val endY = centerYAxis * (1 + sizeFactor) + + if (startY != endY) { + drawLine(xAxis, startY, xAxis, endY, paint) + } else if (drawPoints) { + pointsList.add(PointF(xAxis, endY)) + } + } + + if (drawPoints && pointsList.isNotEmpty()) { + pointsList.forEach { point -> + drawCircle(point.x, point.y, spikesWidth / 2f, paint) + } + } +} + +fun Canvas.drawTimeLineCompressed( + totalDuration: Duration, + sampleSize: Int = 100, + outlineColor: Int, + outlineVariant: Int, + strokeWidthThick: Float = 2f, + strokeWidthLight: Float = 1f, + textColor: Int, + textSize: Float, + viewWidth: Int, + viewHeight: Int, + dpToPx: (Float) -> Float +) { + val blockTime = totalDuration.inWholeMilliseconds / sampleSize + val spacing = viewWidth.toFloat() / sampleSize + + val paintThick = Paint().apply { + color = outlineColor + strokeWidth = strokeWidthThick + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + val paintLight = Paint().apply { + color = outlineVariant + strokeWidth = strokeWidthLight + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + val textPaint = TextPaint().apply { + color = textColor + this.textSize = textSize + isAntiAlias = true + typeface = Typeface.MONOSPACE + textAlign = Paint.Align.CENTER + } + + repeat(sampleSize + 20) { idx -> + val xAxis = idx * spacing + if (idx % 20 == 0) { + val time = (blockTime * idx).milliseconds + val minutes = time.inWholeMinutes + val seconds = (time.inWholeSeconds % 60) + val readable = String.format(Locale.ENGLISH, "%02d:%02d", minutes, seconds) + + val textY = dpToPx(12f) + drawText(readable, xAxis, textY, textPaint) + + drawLine(xAxis, 0f, xAxis, dpToPx(8f), paintThick) + drawLine(xAxis, viewHeight - dpToPx(8f), xAxis, viewHeight.toFloat(), paintThick) + } else if (idx % 5 == 0) { + drawLine(xAxis, 0f, xAxis, dpToPx(4f), paintLight) + drawLine(xAxis, viewHeight - dpToPx(4f), xAxis, viewHeight.toFloat(), paintLight) + } + } +} + +fun Canvas.drawTimeLine( + totalDurationInMillis: Long, + textSizeInSp: Float, + outlineColor: Int = Color.GRAY, + outlineVariant: Int = Color.GRAY, + spikesWidth: Float = 2f, + strokeWidthThick: Float = 2f, + strokeWidthLight: Float = 1f, + textColor: Int = Color.BLACK, + sampleSize: Int = 100, + dpToPx: (Float) -> Float, + typeface: Typeface = Typeface.MONOSPACE, + topPadding: Int = 0, + bottomPadding: Int = 0, + leftPadding: Int = 0, +) { + val durationAsMillis = (totalDurationInMillis + 2 * 1_000).toInt() + val spacing = spikesWidth / sampleSize + + val paintThick = Paint().apply { + color = outlineColor + strokeWidth = strokeWidthThick + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + val paintLight = Paint().apply { + color = outlineVariant + strokeWidth = strokeWidthLight + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + } + + val textPaint = TextPaint().apply { + color = textColor + this.textSize = textSizeInSp + isAntiAlias = true + textAlign = Paint.Align.CENTER + this.typeface = typeface + } + + repeat(durationAsMillis) { millis -> + val xAxis = millis * spacing + leftPadding + if (millis % 2000 == 0) { + val time = millis.milliseconds + val minutes = time.inWholeMinutes + val seconds = (time.inWholeSeconds % 60) + val readable = String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + + val textY = dpToPx(12f).unaryMinus() - (textPaint.descent() + textPaint.ascent()) / 2 + drawText(readable, xAxis, topPadding + textY, textPaint) + + drawLine(xAxis, topPadding.toFloat(), xAxis, topPadding + dpToPx(8f), paintThick) + drawLine( + xAxis, + height - dpToPx(8f) - bottomPadding, + xAxis, + height.toFloat() - bottomPadding, + paintThick + ) + } else if (millis % 500 == 0) { + drawLine(xAxis, topPadding.toFloat(), xAxis, topPadding + dpToPx(4f), paintLight) + drawLine( + xAxis, + height - dpToPx(4f) - bottomPadding, + xAxis, + height.toFloat() - bottomPadding, + paintLight + ) + } + } +} + +fun Canvas.drawTimeLineWithBookMarks( + totalDurationInMillis: Long, + bookMarks: Set, + dpToPx: (Float) -> Float, + imageSize: Float = 20f, + textSizeInSp: Float = 16f, + bookMarkDrawable: Drawable? = null, + sampleSize: Int = 100, + outlineColor: Int = Color.WHITE, + outlineVariant: Int = Color.WHITE, + bookMarkColor: Int = Color.WHITE, + spikesWidth: Float = 2f, + strokeWidthThick: Float = 2f, + strokeWidthLight: Float = 1f, + bookMarkStokeWidth: Float = 2f, + textColor: Int = Color.WHITE, + typeface: Typeface = Typeface.MONOSPACE, + leftPadding: Int = 0, + topPadding: Int = 0, + bottomPadding: Int = 0, +) { + drawTimeLine( + totalDurationInMillis = totalDurationInMillis, + sampleSize = sampleSize, + outlineColor = outlineColor, + outlineVariant = outlineVariant, + strokeWidthThick = strokeWidthThick, + strokeWidthLight = strokeWidthLight, + textColor = textColor, + textSizeInSp = textSizeInSp, + spikesWidth = spikesWidth, + dpToPx = dpToPx, + typeface = typeface, + topPadding = topPadding, + bottomPadding = bottomPadding, + leftPadding = leftPadding, + ) + + val spacing = spikesWidth / sampleSize + + val bookMarkPaint = Paint().apply { + color = bookMarkColor + strokeWidth = bookMarkStokeWidth + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + style = Paint.Style.FILL + } + + bookMarks.forEach { timeInMillis -> + val xAxis = timeInMillis * spacing + leftPadding + + // Draw vertical line + drawLine( + xAxis, + topPadding + dpToPx(2f), + xAxis, + height - dpToPx(2f) - bottomPadding, + bookMarkPaint + ) + // Draw circle at top + drawCircle(xAxis, topPadding + dpToPx(2f), dpToPx(3f), bookMarkPaint) + + // Draw bookmark icon + bookMarkDrawable?.let { drawable -> + withTranslation(xAxis - (imageSize / 2f), height + dpToPx(4f) - bottomPadding) { + drawable.setBounds(0, 0, imageSize.toInt(), imageSize.toInt()) + drawable.setTint(bookMarkColor) + drawable.draw(this) + } + } + } +} + +fun Canvas.drawTrackPointer( + xAxis: Float, + color: Int, + radius: Float = 1f, + strokeWidth: Float = 1f, + topPadding: Int = 0, + bottomPadding: Int = 0, +) { + val paint = Paint().apply { + this.color = color + this.strokeWidth = strokeWidth + strokeCap = Paint.Cap.ROUND + isAntiAlias = true + style = Paint.Style.FILL + } + + // Draw circles + drawCircle(xAxis, topPadding.toFloat(), radius, paint) + drawCircle(xAxis, (height - bottomPadding).toFloat(), radius, paint) + + // Draw line + paint.style = Paint.Style.STROKE + drawLine(xAxis, topPadding.toFloat(), xAxis, (height - bottomPadding).toFloat(), paint) +} \ No newline at end of file From 1d8a130928bd2dec1fdf42f827bc32d8abcf8fec Mon Sep 17 00:00:00 2001 From: tuuhin Date: Fri, 26 Dec 2025 23:23:48 +0530 Subject: [PATCH 06/11] Include a scroll listener The scroll listener can be later used to change the player position directly via the graph container --- .../views/GraphScrollListener.kt | 120 ++++++++++++++++++ .../views/PlayerAmplitudeGraph2View.kt | 32 +++++ 2 files changed, 152 insertions(+) create mode 100644 feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt diff --git a/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt b/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt new file mode 100644 index 00000000..28c4e706 --- /dev/null +++ b/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt @@ -0,0 +1,120 @@ +package com.eva.feature_player.views + +import android.util.Log +import android.view.Choreographer +import android.view.GestureDetector +import android.view.MotionEvent +import kotlin.math.abs + +private const val TAG = "GraphScrollListener" + +internal class GraphScrollListener( + private val totalContentWidthProvider: () -> Float, + private val onScrollStart: (Float) -> Unit = {}, + private val onScrollEnd: () -> Unit = {}, + private val onScroll: (Float) -> Unit, +) : GestureDetector.SimpleOnGestureListener(), Choreographer.FrameCallback { + + private var _isScrolling = false + private var _isFlinging = false + private var _lastFrameTimeNanos = 0L + + private var _scrollRatioInternal = 0.0f + private var _flingVelocity = 0f + + private val hitBoundary: Boolean + get() = _scrollRatioInternal == 0f || _scrollRatioInternal == 1f + + override fun onDown(e: MotionEvent): Boolean { + stopFling() + return true + } + + override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float) + : Boolean { + // Convert pixel distance to ratio change + val totalContentWidth = totalContentWidthProvider() + if (totalContentWidth <= 0f) return false + val deltaRatio = distanceX / totalContentWidth + _scrollRatioInternal = (_scrollRatioInternal + deltaRatio).coerceIn(0f, 1f) + onScroll(_scrollRatioInternal) + return true + } + + override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float) + : Boolean { + startFling(velocityX) + return true + } + + override fun doFrame(frameTimeNanos: Long) { + if (!_isFlinging) return + + if (_lastFrameTimeNanos == 0L) { + _lastFrameTimeNanos = frameTimeNanos + // add the callback for the next frame + Choreographer.getInstance().postFrameCallback(this) + return + } + + // Calculate delta time in seconds + val dt = (frameTimeNanos - _lastFrameTimeNanos) / 1_000_000_000f + _lastFrameTimeNanos = frameTimeNanos + + // Update scroll position + val previousRatio = _scrollRatioInternal + _scrollRatioInternal = (previousRatio + _flingVelocity * dt).coerceIn(0f, 1f) + + onScroll(_scrollRatioInternal) + _flingVelocity *= 0.98f + + // slow down the fling velocity until it's too slow + if (abs(_flingVelocity) < 0.005f || hitBoundary) { + stopFling() + return + } + // On each frame we include the callback so convert the fling velocity to cancellations + Choreographer.getInstance().postFrameCallback(this) + } + + private fun pxToRatioVelocity(velocityX: Float): Float { + val totalContentWidth = totalContentWidthProvider() + if (totalContentWidth <= 0f) return 0f + return -(velocityX / totalContentWidth) * 1.5f + } + + private fun startFling(velocityX: Float) { + stopFling() + + _flingVelocity = pxToRatioVelocity(velocityX) + _isFlinging = true + _lastFrameTimeNanos = 0L + Log.d(TAG, "FLING STARTED: VELOCITY :$velocityX") + Choreographer.getInstance().postFrameCallback(this) + } + + private fun stopFling() { + if (!_isFlinging) return + + _isFlinging = false + _flingVelocity = 0f + _lastFrameTimeNanos = 0L + + Log.d(TAG, "FLING STOPPED") + // stop the frame callback + Choreographer.getInstance().removeFrameCallback(this) + } + + fun markScrollEnd() { + _isScrolling = false + Log.d(TAG, "SCROLL ENDED") + onScrollEnd() + } + + fun markScrollStarted(positionX: Float) { + _isScrolling = true + stopFling() + Log.d(TAG, "SCROLL STARTED =$positionX") + onScrollStart(positionX) + } +} \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt index e6b3875f..2c2f135f 100644 --- a/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt +++ b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt @@ -10,6 +10,8 @@ import android.graphics.Typeface import android.os.SystemClock import android.util.Log import android.util.TypedValue +import android.view.GestureDetector +import android.view.MotionEvent import android.view.TextureView import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.createBitmap @@ -68,10 +70,25 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context var textTypeface: Typeface = Typeface.MONOSPACE var textFontSizeAsPx: Float = 12f + // resources private val _bookMarkDrawable by lazy { ResourcesCompat.getDrawable(resources, R.drawable.ic_bookmark, null) } + //callbacks + private var _onPlayPosChangeViaScrollStart: ((Float) -> Unit)? = null + private var _onPlayPosChangeViaScroll: ((Float) -> Unit)? = null + private var _onPlayPosChangeViaScrollStop: (() -> Unit)? = null + + private val _gestureDetectorListener = GraphScrollListener( + totalContentWidthProvider = { _timelineCacheBitmap?.width?.toFloat() ?: 0f }, + onScrollStart = { _onPlayPosChangeViaScrollStart?.invoke(it) }, + onScrollEnd = { _onPlayPosChangeViaScrollStop?.invoke() }, + onScroll = { ratio -> _onPlayPosChangeViaScroll?.invoke(ratio) } + ) + + private val _gestureDetector by lazy { GestureDetector(context, _gestureDetectorListener) } + init { surfaceTextureListener = this isOpaque = true @@ -105,6 +122,17 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context Log.i(TAG, "SURFACE TEXTURE UPDATED") } + override fun onTouchEvent(event: MotionEvent): Boolean { + parent?.requestDisallowInterceptTouchEvent(true) + val handleEvents = _gestureDetector.onTouchEvent(event) + when (event.actionMasked) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> _gestureDetectorListener.markScrollEnd() + MotionEvent.ACTION_DOWN -> _gestureDetectorListener.markScrollStarted(event.x) + } + // cancel touch events for the parent + return handleEvents || super.onTouchEvent(event) + } + private fun initiateCacheBitmaps(width: Int, height: Int) { // recycle and clear the old bitmap _timelineCacheBitmap?.recycle() @@ -319,6 +347,10 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context _playRatio = ratio() } + fun onScrollToChangePlayPosition(listener: (Float) -> Unit) { + _onPlayPosChangeViaScroll = listener + } + fun cleanUp() { if (_renderThread?.state != Thread.State.TERMINATED) { _renderThread?.join() From 79419031ef2182236c3ff591b22433ce57119079 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sat, 27 Dec 2025 23:32:02 +0530 Subject: [PATCH 07/11] Corrected some logic for scroll and player graph Still some improvements needed ViewExtensions.kt corrected drawGraph padding issues PlayerAmplitudeGraph2View.kt reusing the same spike space logic and some additional logs Made it compatible with scroll and scroll end api in PlayerAmplitudeGraph2.kt These callbacks will be later used to scroll the container --- .../composable/PlayerAmplitudeGraph2.kt | 19 ++- .../views/GraphScrollListener.kt | 10 +- .../views/PlayerAmplitudeGraph2View.kt | 131 +++++++++++------- .../feature_player/views/ViewExtensions.kt | 125 +++-------------- 4 files changed, 126 insertions(+), 159 deletions(-) diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt index 7b48786d..8580bd68 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerAmplitudeGraph2.kt @@ -41,6 +41,9 @@ fun PlayerAmplitudeGraph2( trackPlayRatio: PlayRatio, graphData: PlayerGraphData, modifier: Modifier = Modifier, + isSwipeToScrollEnabled: Boolean = false, + onSwipe: (ratio: Float) -> Unit = {}, + onSwipeEnd: () -> Unit = {}, bookMarkTimeStamps: ImmutableList = persistentListOf(), plotColor: Color = MaterialTheme.colorScheme.secondary, trackPointerColor: Color = MaterialTheme.colorScheme.primary, @@ -87,16 +90,21 @@ fun PlayerAmplitudeGraph2( contentPadding.calculateBottomPadding().roundToPx(), ) } + view.isSwipeToScrollEnabled = isSwipeToScrollEnabled + // callbacks + view.onSwipeToChangeEnd(onSwipeEnd) + view.onSwipeToChangePlayPosition(onSwipe) } }, update = { view -> + // enable gesture detection + view.isSwipeToScrollEnabled = isSwipeToScrollEnabled view.onUpdateTrackDuration(totalTrackDuration) view.onUpdateBookMarks(bookMarkTimeStamps) view.onUpdatePlotColor(plotColor.toArgb()) view.onUpdatePlayRatio { trackPlayRatio() } view.onUpdateGraphData { graphData() } }, - onReset = { view -> view.cleanUp() }, onRelease = { view -> view.cleanUp() }, modifier = modifier.defaultMinSize(minHeight = dimensionResource(id = R.dimen.line_graph_min_height)) ) @@ -117,6 +125,9 @@ internal fun PlayerAmplitudeGraph2( timelineTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh, timelineFontFamily: FontFamily = FontFamily.Monospace, + isSwipeToScrollEnabled: Boolean = false, + onSeek: (Duration) -> Unit = {}, + onSeekEnd: () -> Unit = {}, shape: Shape = MaterialTheme.shapes.small, contentPadding: PaddingValues = PaddingValues( horizontal = dimensionResource(id = R.dimen.graph_card_padding), @@ -135,6 +146,12 @@ internal fun PlayerAmplitudeGraph2( totalTrackDuration = totalDuration, graphData = graphData, bookMarkTimeStamps = bookMarksTimeStamps, + isSwipeToScrollEnabled = isSwipeToScrollEnabled, + onSwipe = { ratio -> + val duration = trackData().calculateSeekAmount(ratio) + onSeek(duration) + }, + onSwipeEnd = onSeekEnd, plotColor = plotColor, trackPointerColor = trackPointerColor, bookMarkColor = bookMarkColor, diff --git a/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt b/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt index 28c4e706..6139846a 100644 --- a/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt +++ b/feature/player/src/main/java/com/eva/feature_player/views/GraphScrollListener.kt @@ -58,18 +58,18 @@ internal class GraphScrollListener( } // Calculate delta time in seconds - val dt = (frameTimeNanos - _lastFrameTimeNanos) / 1_000_000_000f + val dTInSec = (frameTimeNanos - _lastFrameTimeNanos) / 1_000_000_000f _lastFrameTimeNanos = frameTimeNanos // Update scroll position val previousRatio = _scrollRatioInternal - _scrollRatioInternal = (previousRatio + _flingVelocity * dt).coerceIn(0f, 1f) + _scrollRatioInternal = (previousRatio + _flingVelocity * dTInSec).coerceIn(0f, 1f) onScroll(_scrollRatioInternal) _flingVelocity *= 0.98f // slow down the fling velocity until it's too slow - if (abs(_flingVelocity) < 0.005f || hitBoundary) { + if (abs(_flingVelocity) < 0.01f || hitBoundary) { stopFling() return } @@ -103,11 +103,15 @@ internal class GraphScrollListener( Log.d(TAG, "FLING STOPPED") // stop the frame callback Choreographer.getInstance().removeFrameCallback(this) + // now call scroll end + onScrollEnd() } fun markScrollEnd() { _isScrolling = false Log.d(TAG, "SCROLL ENDED") + // don't call scroll end if flinging is on + if (_isFlinging) return onScrollEnd() } diff --git a/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt index 2c2f135f..61e9970b 100644 --- a/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt +++ b/feature/player/src/main/java/com/eva/feature_player/views/PlayerAmplitudeGraph2View.kt @@ -26,6 +26,7 @@ import kotlin.math.roundToInt import kotlin.time.Duration private const val TAG = "PLAYER_AMPLITUDE_GRAPH_2" +private const val TEXTURE_VIEW_TAG = "PLAYER_TEXTURE_LISTENER" @OptIn(ExperimentalAtomicApi::class) internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context), @@ -75,15 +76,14 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context ResourcesCompat.getDrawable(resources, R.drawable.ic_bookmark, null) } - //callbacks - private var _onPlayPosChangeViaScrollStart: ((Float) -> Unit)? = null + // swipe to scroll + var isSwipeToScrollEnabled = false private var _onPlayPosChangeViaScroll: ((Float) -> Unit)? = null - private var _onPlayPosChangeViaScrollStop: (() -> Unit)? = null + private var _onPlayPosChangeViaScrollEnd: (() -> Unit)? = null private val _gestureDetectorListener = GraphScrollListener( totalContentWidthProvider = { _timelineCacheBitmap?.width?.toFloat() ?: 0f }, - onScrollStart = { _onPlayPosChangeViaScrollStart?.invoke(it) }, - onScrollEnd = { _onPlayPosChangeViaScrollStop?.invoke() }, + onScrollEnd = { _onPlayPosChangeViaScrollEnd?.invoke() }, onScroll = { ratio -> _onPlayPosChangeViaScroll?.invoke(ratio) } ) @@ -92,13 +92,20 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context init { surfaceTextureListener = this isOpaque = true + isClickable = true } override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { - _renderThread = Thread(renderLoop(), "RENDERER_THREAD_") + _renderThread = Thread(renderLoop(), "thGraphRenderer_") _renderThread?.start() - Log.i(TAG, "RENDERER THREAD IS ACTIVE") + Log.d(TEXTURE_VIEW_TAG, "RENDERER THREAD IS ACTIVE") _isThRunning = true + + // init bitmap cache from the given width and height + if (_graphCacheBitmap == null || _timelineCacheBitmap == null) { + Log.d(TEXTURE_VIEW_TAG, "RE-INITIATE CACHED BITMAPS") + initiateCacheBitmaps(width, height) + } } override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { @@ -106,25 +113,28 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context _isThRunning = false _renderThread?.join(1000) } catch (e: Exception) { - Log.e(TAG, "THREAD CLEANUP", e) + Log.e(TEXTURE_VIEW_TAG, "THREAD CLEANUP", e) } - Log.i(TAG, "RENDERER THREAD IS CLEANED! ${_renderThread?.state}") + Log.d(TEXTURE_VIEW_TAG, "RENDERER THREAD IS CLEANED! ${_renderThread?.state}") + // surface view is released return true } override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { - if (width <= 0 || height <= 0) return + Log.d(TEXTURE_VIEW_TAG, "SURFACE TEXTURE SIZE CHANGED") + // reinitiate bitmap cache as texture size changed initiateCacheBitmaps(width, height) - Log.i(TAG, "SURFACE TEXTURE SIZE CHANGED") } - override fun onSurfaceTextureUpdated(surface: SurfaceTexture) { - Log.i(TAG, "SURFACE TEXTURE UPDATED") - } + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) = Unit + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!isSwipeToScrollEnabled) return super.onTouchEvent(event) + // if swipe to scroll enabled then only allow the gesture detection parent?.requestDisallowInterceptTouchEvent(true) val handleEvents = _gestureDetector.onTouchEvent(event) + if (event.action == MotionEvent.ACTION_UP) performClick() when (event.actionMasked) { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> _gestureDetectorListener.markScrollEnd() MotionEvent.ACTION_DOWN -> _gestureDetectorListener.markScrollStarted(event.x) @@ -133,31 +143,48 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context return handleEvents || super.onTouchEvent(event) } - private fun initiateCacheBitmaps(width: Int, height: Int) { - // recycle and clear the old bitmap - _timelineCacheBitmap?.recycle() - _graphCacheBitmap?.recycle() - _timelineCacheBitmap = null - _graphCacheBitmap = null + private fun initiateCacheBitmaps( + width: Int, + height: Int, + resetTimelineCache: Boolean = true, + resetGraphCache: Boolean = true + ) { + if (width <= 0 || height <= 0) { + Log.w(TAG, "INVALID AREA") + return + } + // evaluate the sizes val maxSamples = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE val spikesWidth = width.toFloat() / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE - val spikeSpace = dpToPx(2f) + val spikeSpace = (spikesWidth - dpToPx(1.5f)).let { amt -> + if (amt > 0f) amt else dpToPx(2.0f) + } val blockWidth = spikesWidth + spikeSpace val probableSampleSize = maxOf(_totalTrackDurationMillis / maxSamples, 10L) val maxWidth = (probableSampleSize * blockWidth + paddingLeft).roundToInt() .coerceAtLeast(width * 2) - // build a timeline based on the probable size - _timelineCacheBitmap = createBitmap(maxWidth, height) - _timelineCacheCanvas = Canvas(_timelineCacheBitmap!!) - _timelineCached.store(false) + if (resetTimelineCache) { + // recycle and clear the old bitmap + _timelineCacheBitmap?.recycle() + _timelineCacheBitmap = null + // build a timeline based on the probable size + _timelineCacheBitmap = createBitmap(maxWidth, height) + _timelineCacheCanvas = Canvas(_timelineCacheBitmap!!) + _timelineCached.store(false) + } - // build an arbitrary graph cache based on the probable size - _graphCacheBitmap = createBitmap(maxWidth, height) - _graphCacheCanvas = Canvas(_graphCacheBitmap!!) - _cachedGraphDataSize = 0 + if (resetGraphCache) { + // clear the graph if any + _graphCacheBitmap?.recycle() + _graphCacheBitmap = null + // build an arbitrary graph cache based on the probable size + _graphCacheBitmap = createBitmap(maxWidth, height) + _graphCacheCanvas = Canvas(_graphCacheBitmap!!) + _cachedGraphDataSize = 0 + } Log.d(TAG, "CREATING TIMELINE AND GRAPH OF SIZE: ${maxWidth}x${height}") } @@ -195,13 +222,12 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context drawColor(canvasBackground) val spikesWidth = width.toFloat() / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE - val spikeSpace = dpToPx(2f) - - val probableSampleSize = maxOf( - _totalTrackDurationMillis / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE, - 100L - ) + val spikeGap = (spikesWidth - dpToPx(1.5f)).let { amt -> + if (amt > 0f) amt else dpToPx(2.0f) + } + val probableSampleSize = + _totalTrackDurationMillis / RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE val sampleSize = maxOf(_graphData.size.toLong(), probableSampleSize) val totalSize = sampleSize * spikesWidth val translate = (width * 0.5f - paddingLeft) - (totalSize * _playRatio) @@ -226,12 +252,11 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context bottomPadding = paddingBottom, leftPadding = paddingLeft ) + Log.d(TAG, "ONE TIME TIMELINE DRAWING DONE!!") _timelineCached.store(true) - Log.d(TAG, "ONE TIME TIMELINE DRAW DONE!!") } // Draw cached timeline - withTranslation(x = translate) { _timelineCacheBitmap?.let { bitmap -> if (bitmap.isRecycled) return@withTranslation @@ -242,8 +267,6 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context val currentDataSize = _graphData.size val cachedSize = _cachedGraphDataSize if (currentDataSize > cachedSize) { - Log.d(TAG, "DRAWING FROM $cachedSize TO $currentDataSize") - // Extract only the new data val newData = _graphData.sliceArray(cachedSize.. 0 && height > 0) initiateCacheBitmaps(width, height) } fun onUpdateBookMarks(bookMarks: List) { @@ -347,8 +375,12 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context _playRatio = ratio() } - fun onScrollToChangePlayPosition(listener: (Float) -> Unit) { - _onPlayPosChangeViaScroll = listener + fun onSwipeToChangeEnd(onScrollEndListener: () -> Unit) { + _onPlayPosChangeViaScrollEnd = onScrollEndListener + } + + fun onSwipeToChangePlayPosition(onScrollListener: (Float) -> Unit) { + _onPlayPosChangeViaScroll = onScrollListener } fun cleanUp() { @@ -361,6 +393,11 @@ internal class PlayerAmplitudeGraph2View(context: Context) : TextureView(context _timelineCacheBitmap = null _graphCacheBitmap?.recycle() _graphCacheBitmap = null - Log.i(TAG, "CLEANUP CODE CALLED") + Log.d(TEXTURE_VIEW_TAG, "CLEANUP CODE CALLED") + + //clear callbacks + _onPlayPosChangeViaScrollEnd = null + _onPlayPosChangeViaScroll = null + Log.d(TAG, "CALLBACKS REMOVED") } } \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt b/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt index d4eb85c5..48c88292 100644 --- a/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt +++ b/feature/player/src/main/java/com/eva/feature_player/views/ViewExtensions.kt @@ -3,13 +3,11 @@ package com.eva.feature_player.views import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint -import android.graphics.PointF import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.TextPaint import androidx.core.graphics.withTranslation import java.util.Locale -import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds fun Canvas.drawGraph( @@ -23,10 +21,11 @@ fun Canvas.drawGraph( bottomPadding: Int = 0, leftPadding: Int = 0, ) { - val centerYAxis = (height - topPadding - bottomPadding) * 0.5f + val totalVPadding = topPadding + bottomPadding + val centerYAxis = (height - totalVPadding) * 0.5f val paint = Paint().apply { this.color = color - strokeWidth = spikesGap + this.strokeWidth = spikesGap strokeCap = Paint.Cap.ROUND isAntiAlias = true } @@ -39,110 +38,20 @@ fun Canvas.drawGraph( val endY = centerYAxis * (1 + sizeFactor) if (startY != endY) { - drawLine(xAxis, topPadding + startY, xAxis, topPadding + endY, paint) - } else if (drawPoints) { - drawCircle(xAxis, topPadding + centerYAxis, spikesGap / 2f, paint) - } - } -} - -fun Canvas.drawGraphCompressed( - waves: FloatArray, - color: Int, - drawPoints: Boolean = true, - spikesWidth: Float = 2f, - viewWidth: Int, - viewHeight: Int -) { - val centerYAxis = viewHeight / 2f - val wavesSize = waves.size - - if (wavesSize == 0) return - - val spacing = viewWidth.toFloat() / (wavesSize + 1) - val pointsList = mutableListOf() - - val paint = Paint().apply { - this.color = color - strokeWidth = spikesWidth - strokeCap = Paint.Cap.ROUND - isAntiAlias = true - } - - waves.forEachIndexed { index, wave -> - val xAxis = (index + 1) * spacing - val sizeFactor = wave * 0.8f - val startY = centerYAxis * (1 - sizeFactor) - val endY = centerYAxis * (1 + sizeFactor) - - if (startY != endY) { - drawLine(xAxis, startY, xAxis, endY, paint) + drawLine( + xAxis, + totalVPadding * .5f + startY, + xAxis, + totalVPadding * .5f + endY, + paint + ) } else if (drawPoints) { - pointsList.add(PointF(xAxis, endY)) - } - } - - if (drawPoints && pointsList.isNotEmpty()) { - pointsList.forEach { point -> - drawCircle(point.x, point.y, spikesWidth / 2f, paint) - } - } -} - -fun Canvas.drawTimeLineCompressed( - totalDuration: Duration, - sampleSize: Int = 100, - outlineColor: Int, - outlineVariant: Int, - strokeWidthThick: Float = 2f, - strokeWidthLight: Float = 1f, - textColor: Int, - textSize: Float, - viewWidth: Int, - viewHeight: Int, - dpToPx: (Float) -> Float -) { - val blockTime = totalDuration.inWholeMilliseconds / sampleSize - val spacing = viewWidth.toFloat() / sampleSize - - val paintThick = Paint().apply { - color = outlineColor - strokeWidth = strokeWidthThick - strokeCap = Paint.Cap.ROUND - isAntiAlias = true - } - - val paintLight = Paint().apply { - color = outlineVariant - strokeWidth = strokeWidthLight - strokeCap = Paint.Cap.ROUND - isAntiAlias = true - } - - val textPaint = TextPaint().apply { - color = textColor - this.textSize = textSize - isAntiAlias = true - typeface = Typeface.MONOSPACE - textAlign = Paint.Align.CENTER - } - - repeat(sampleSize + 20) { idx -> - val xAxis = idx * spacing - if (idx % 20 == 0) { - val time = (blockTime * idx).milliseconds - val minutes = time.inWholeMinutes - val seconds = (time.inWholeSeconds % 60) - val readable = String.format(Locale.ENGLISH, "%02d:%02d", minutes, seconds) - - val textY = dpToPx(12f) - drawText(readable, xAxis, textY, textPaint) - - drawLine(xAxis, 0f, xAxis, dpToPx(8f), paintThick) - drawLine(xAxis, viewHeight - dpToPx(8f), xAxis, viewHeight.toFloat(), paintThick) - } else if (idx % 5 == 0) { - drawLine(xAxis, 0f, xAxis, dpToPx(4f), paintLight) - drawLine(xAxis, viewHeight - dpToPx(4f), xAxis, viewHeight.toFloat(), paintLight) + drawCircle( + xAxis, + totalVPadding * .5f + centerYAxis, + spikesGap / 2f, + paint + ) } } } @@ -196,7 +105,7 @@ fun Canvas.drawTimeLine( val seconds = (time.inWholeSeconds % 60) val readable = String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) - val textY = dpToPx(12f).unaryMinus() - (textPaint.descent() + textPaint.ascent()) / 2 + val textY = dpToPx(8f).unaryMinus() - (textPaint.descent() + textPaint.ascent()) / 2 drawText(readable, xAxis, topPadding + textY, textPaint) drawLine(xAxis, topPadding.toFloat(), xAxis, topPadding + dpToPx(8f), paintThick) From b8be3141f345137449a98f5027d7535a5054c1cd Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 28 Dec 2025 11:15:11 +0530 Subject: [PATCH 08/11] So we have another version of PlayerTrackSlider2.kt Now rather than keeping the slide state in the compose function its moved to PlayerTrackUIState.kt which is later manage by the viewmodel itself PlayerTrackUIState.kt manages user interaction of the slider, alongside the track data without changing the player timeline Later in PlayerTrackSlider2.kt we update the track ratio PlayerTrackSlider.kt cannot be used any more as its a compose managed state so it will create disparity Events are passed to the viewmodel and the AudioEditorViewModel.kt and AudioPlayerViewModel.kt manages the state --- .../composables/EditorActionsAndControls.kt | 8 ++- .../feature_editor/event/EditorScreenEvent.kt | 4 +- .../viewmodel/AudioEditorViewModel.kt | 27 +++++--- .../composables/PlayerTrackSlider.kt | 12 +++- .../composables/PlayerTrackSlider2.kt | 61 +++++++----------- .../player_shared/state/PlayerTrackUIState.kt | 63 +++++++++++++++++++ .../composable/PlayerActionsAndSlider.kt | 3 +- .../eva/feature_player/state/PlayerEvents.kt | 4 +- .../viewmodel/AudioPlayerViewModel.kt | 48 +++++++++----- 9 files changed, 162 insertions(+), 68 deletions(-) create mode 100644 feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerTrackUIState.kt diff --git a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt index 6b0454e7..5cc503cb 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/composables/EditorActionsAndControls.kt @@ -44,6 +44,7 @@ private fun EditorActionsAndControls( onCutMedia: () -> Unit, onPlay: () -> Unit, onPause: () -> Unit, + onSeekEnd: () -> Unit = {}, playButtonColor: Color = MaterialTheme.colorScheme.primary, actionButtonColor: Color = MaterialTheme.colorScheme.secondary, ) { @@ -54,7 +55,8 @@ private fun EditorActionsAndControls( ) { PlayerTrackSlider2( trackData = trackData, - onSeekComplete = onSeek + onSeek = onSeek, + onSeekEnd = onSeekEnd ) //actions Row( @@ -158,6 +160,8 @@ internal fun EditorActionsAndControls( onSeek = { onEvent(EditorScreenEvent.OnSeekTrack(it)) }, onPlay = { onEvent(EditorScreenEvent.PlayAudio) }, onPause = { onEvent(EditorScreenEvent.PauseAudio) }, + onSeekEnd = { onEvent(EditorScreenEvent.OnSeekTrackEnd) }, onCropMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CROP)) }, - onCutMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CUT)) }) + onCutMedia = { onEvent(EditorScreenEvent.OnEditAction(AudioEditAction.CUT)) }, + ) } \ No newline at end of file diff --git a/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt b/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt index 562cbdf7..b63e7a9d 100644 --- a/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt +++ b/feature/editor/src/main/java/com/eva/feature_editor/event/EditorScreenEvent.kt @@ -10,7 +10,6 @@ sealed interface EditorScreenEvent { data object PauseAudio : EditorScreenEvent data class OnClipConfigChange(val config: AudioClipConfig) : EditorScreenEvent - data class OnSeekTrack(val duration: Duration) : EditorScreenEvent data class OnEditAction(val action: AudioEditAction) : EditorScreenEvent @@ -21,4 +20,7 @@ sealed interface EditorScreenEvent { data object OnCancelTransformation : EditorScreenEvent data object OnDismissExportSheet : EditorScreenEvent data object OnSaveExportFile : EditorScreenEvent + + data class OnSeekTrack(val duration: Duration) : EditorScreenEvent + data object OnSeekTrackEnd : EditorScreenEvent } \ No newline at end of file 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 9c8da937..d48877c2 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 @@ -13,6 +13,7 @@ import com.eva.feature_editor.event.TransformationState import com.eva.feature_editor.undoredo.UndoRedoManager import com.eva.feature_editor.undoredo.UndoRedoState import com.eva.player.domain.model.PlayerTrackData +import com.eva.player_shared.state.PlayerTrackUIState import com.eva.recordings.domain.models.AudioFileModel import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.ui.viewmodel.AppViewModel @@ -49,6 +50,8 @@ internal class AudioEditorViewModel @AssistedInject constructor( private val player: SimpleAudioPlayer, ) : AppViewModel() { + private val _trackUIState = PlayerTrackUIState() + private val _currentFile = MutableStateFlow(null) private val _lastEditAction = MutableStateFlow(AudioEditAction.CROP) private val _exportFileUri = MutableStateFlow(null) @@ -98,11 +101,12 @@ internal class AudioEditorViewModel @AssistedInject constructor( initialValue = false ) - val trackData = player.trackInfoAsFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(1_000L), - initialValue = PlayerTrackData() - ) + val trackData = _trackUIState.controllablePlayerTrackData(player.trackInfoAsFlow) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1_000L), + initialValue = PlayerTrackData() + ) private val _uiEvents = MutableSharedFlow() override val uiEvent: SharedFlow @@ -113,7 +117,6 @@ internal class AudioEditorViewModel @AssistedInject constructor( EditorScreenEvent.PauseAudio -> viewModelScope.launch { player.pausePlayer() } EditorScreenEvent.PlayAudio -> viewModelScope.launch { player.startOrResumePlayer() } is EditorScreenEvent.OnClipConfigChange -> updateClipConfig(event.config) - is EditorScreenEvent.OnSeekTrack -> player.onSeekDuration(event.duration) is EditorScreenEvent.OnEditAction -> validateAndApplyEditViaAction(event.action) EditorScreenEvent.BeginTransformation -> finalExport() EditorScreenEvent.OnDismissExportSheet -> onCancelExport() @@ -121,6 +124,13 @@ internal class AudioEditorViewModel @AssistedInject constructor( EditorScreenEvent.OnRedoEdit -> onUndoOrRedoConfigs(false) EditorScreenEvent.OnUndoEdit -> onUndoOrRedoConfigs(true) EditorScreenEvent.OnCancelTransformation -> cancelFinalExport() + is EditorScreenEvent.OnSeekTrack -> viewModelScope.launch { + _trackUIState.onSliderValueChange(event.duration) + } + + EditorScreenEvent.OnSeekTrackEnd -> viewModelScope.launch { + _trackUIState.onInteractionFinished { duration -> player.onSeekDuration(duration) } + } } fun setPlayerItem() = viewModelScope.launch { @@ -211,8 +221,9 @@ internal class AudioEditorViewModel @AssistedInject constructor( val fileModel = _currentFile.value ?: return val clipData = _clipData.updateAndGet { clipConfig } ?: 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) + 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) { diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt index 086d9b3e..8b623424 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider.kt @@ -18,10 +18,18 @@ import kotlinx.coroutines.launch import kotlin.math.round import kotlin.time.Duration +@Deprecated( + message = "This version of the player track slider is im compatible with track data info,the player state is being hoisted outside the composable scope use PlayerTrackSlider2", + replaceWith = ReplaceWith( + "PlayerTrackSlider2", + "com.eva.player_shared.composables.PlayerTrackSlider2" + ), + level = DeprecationLevel.ERROR, +) @Composable fun PlayerTrackSlider( trackData: PlayerTrackData, - onSeekComplete: (Duration) -> Unit, + onSeek: (Duration) -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true ) { @@ -53,7 +61,7 @@ fun PlayerTrackSlider( onValueChangeFinished = { scope.launch { controller.sliderCleanUp { - onSeekComplete(seekAmountByUser) + onSeek(seekAmountByUser) } } }, diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt index 8e53b9e1..dede22ed 100644 --- a/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt +++ b/feature/player-shared/src/main/java/com/eva/player_shared/composables/PlayerTrackSlider2.kt @@ -1,5 +1,6 @@ package com.eva.player_shared.composables +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider @@ -10,16 +11,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.eva.player.domain.model.PlayerTrackData -import com.eva.player_shared.state.PlayerSliderController import com.eva.ui.theme.RecorderAppTheme -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlin.time.Duration @@ -29,42 +26,31 @@ import kotlin.time.Duration.Companion.seconds @Composable fun PlayerTrackSlider2( trackData: () -> PlayerTrackData, - onSeekComplete: (Duration) -> Unit, + onSeek: (Duration) -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + onSeekEnd: () -> Unit = {}, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - // slider controller - val controller = remember { PlayerSliderController() } - val isUserControlled by controller.isSeekByUser.collectAsStateWithLifecycle(false) - val seekAmountByUser by controller.seekAmountByUser.collectAsStateWithLifecycle() - - val currentOnSeekComplete by rememberUpdatedState(onSeekComplete) - - val state = remember { SliderState(value = trackData().playRatio) } + val state = remember { + SliderState( + value = trackData().playRatio, + valueRange = 0f..1f, + onValueChangeFinished = onSeekEnd, + ).also { state -> + // on value change will be called only when the value changed completed + // via interactions not update + state.onValueChange = { value -> + val seekAmount = trackData().calculateSeekAmount(value) + onSeek(seekAmount) + } + } + } + // update the track ratio when changed LaunchedEffect(state) { - - // Basic state update updated by the player snapshotFlow { trackData().playRatio } - .filter { !state.isDragging } - .onEach { state.value = it } - .launchIn(this) - - // If the slider is being drag , updated by the user - snapshotFlow { state.value } - .filter { state.isDragging } - .onEach { seek -> - val playerSeekAmount = trackData().calculateSeekAmount(seek) - controller.onSliderSlide(playerSeekAmount) - }.launchIn(this) - - // now if it's not being dragged but user controlled then send seek completed - snapshotFlow { !state.isDragging && isUserControlled } - .filter { it } - .onEach { - controller.sliderCleanUp() - currentOnSeekComplete(seekAmountByUser) - } + .onEach { value -> state.value = value } .launchIn(this) } @@ -76,7 +62,8 @@ fun PlayerTrackSlider2( thumbColor = MaterialTheme.colorScheme.primary, inactiveTrackColor = MaterialTheme.colorScheme.outlineVariant ), - modifier = modifier + modifier = modifier, + interactionSource = interactionSource, ) } @@ -87,6 +74,6 @@ private fun PlayerTrackSlider2Preview() = RecorderAppTheme { PlayerTrackSlider2( trackData = { trackState }, - onSeekComplete = { trackState = trackState.copy(current = it) }, + onSeek = { trackState = trackState.copy(current = it) }, ) } \ No newline at end of file diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerTrackUIState.kt b/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerTrackUIState.kt new file mode 100644 index 00000000..4b3ec6f3 --- /dev/null +++ b/feature/player-shared/src/main/java/com/eva/player_shared/state/PlayerTrackUIState.kt @@ -0,0 +1,63 @@ +package com.eva.player_shared.state + +import android.util.Log +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import com.eva.player.domain.model.PlayerTrackData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +private const val TAG = "TrackUIState" + +@OptIn( + FlowPreview::class, + ExperimentalCoroutinesApi::class +) +class PlayerTrackUIState { + + private val _mutex = MutatorMutex() + + private val _seekAmountByUser = MutableStateFlow(Duration.ZERO) + private val _isSeekByUser = MutableStateFlow(false) + + fun controllablePlayerTrackData(originalTrackData: Flow): Flow { + return _isSeekByUser + .debounce { isSeeking -> if (isSeeking) 0.milliseconds else 75.milliseconds } + .distinctUntilChanged() + .flatMapLatest { isSeeking -> + Log.d(TAG, "IS SEEKING $isSeeking") + // if the user is not seeking the item original track data is given + if (!isSeeking) originalTrackData + // now user is seeking so we provide the seek data + else combine(originalTrackData, _seekAmountByUser) { track, current -> + track.copy(current = current) + } + } + } + + suspend fun onSliderValueChange(seekAmount: Duration) { + _mutex.mutate(MutatePriority.UserInput) { + Log.d(TAG, "SLIDER POSITION CHANGE") + _isSeekByUser.value = true + _seekAmountByUser.value = seekAmount + } + } + + suspend fun onInteractionFinished(onSeekComplete: (Duration) -> Unit) { + if (!_isSeekByUser.value) return + Log.d(TAG, "SLIDER INTERACTION COMPLETED") + // Complete the seek operation + _mutex.mutate(MutatePriority.PreventUserInput) { + onSeekComplete(_seekAmountByUser.value) + _isSeekByUser.value = false + } + } +} \ No newline at end of file diff --git a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt index 8fa07142..a4406822 100644 --- a/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt +++ b/feature/player/src/main/java/com/eva/feature_player/composable/PlayerActionsAndSlider.kt @@ -37,7 +37,8 @@ internal fun PlayerActionsAndSlider( ) { PlayerTrackSlider2( trackData = trackData, - onSeekComplete = { amount -> onPlayerAction(PlayerEvents.OnSeekPlayer(amount)) }, + onSeek = { amount -> onPlayerAction(PlayerEvents.OnSeekingPlayer(amount)) }, + onSeekEnd = { onPlayerAction(PlayerEvents.OnSeekEndPlayer) }, enabled = isControllerSet ) AudioPlayerActions( diff --git a/feature/player/src/main/java/com/eva/feature_player/state/PlayerEvents.kt b/feature/player/src/main/java/com/eva/feature_player/state/PlayerEvents.kt index eb1c215e..2e1553a4 100644 --- a/feature/player/src/main/java/com/eva/feature_player/state/PlayerEvents.kt +++ b/feature/player/src/main/java/com/eva/feature_player/state/PlayerEvents.kt @@ -16,5 +16,7 @@ internal sealed interface PlayerEvents { data class OnPlayerSpeedChange(val speed: PlayerPlayBackSpeed) : PlayerEvents data class OnRepeatModeChange(val canRepeat: Boolean) : PlayerEvents - data class OnSeekPlayer(val amount: Duration) : PlayerEvents + + data class OnSeekingPlayer(val amount: Duration) : PlayerEvents + object OnSeekEndPlayer : PlayerEvents } \ No newline at end of file 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 432b96d3..cffc8470 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 @@ -6,6 +6,7 @@ import com.eva.feature_player.state.PlayerEvents import com.eva.player.domain.AudioFilePlayer import com.eva.player.domain.model.PlayerMetaData import com.eva.player.domain.model.PlayerTrackData +import com.eva.player_shared.state.PlayerTrackUIState import com.eva.recordings.domain.models.AudioFileModel import com.eva.recordings.domain.provider.PlayerFileProvider import com.eva.ui.viewmodel.AppViewModel @@ -35,28 +36,33 @@ internal class AudioPlayerViewModel @AssistedInject constructor( private val player: AudioFilePlayer, ) : AppViewModel() { + private val _trackController = PlayerTrackUIState() + private val _currentFile = MutableStateFlow(null) private val _currentFileDistinctById = _currentFile .filterNotNull() .distinctUntilChangedBy { it.id } - val playerMetaData = player.playerMetaDataFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = PlayerMetaData() - ) + val playerMetaData = player.playerMetaDataFlow + .stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = PlayerMetaData() + ) - val isPlayerPlaying = player.isPlaying.stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = false - ) + val isPlayerPlaying = player.isPlaying + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = false + ) - val trackData = player.trackInfoAsFlow.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = PlayerTrackData() - ) + val trackData = _trackController.controllablePlayerTrackData(player.trackInfoAsFlow) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PlayerTrackData() + ) val isControllerReady = player.isControllerReady .onStart { @@ -100,7 +106,17 @@ internal class AudioPlayerViewModel @AssistedInject constructor( is PlayerEvents.OnPlayerSpeedChange -> player.setPlayBackSpeed(event.speed) is PlayerEvents.OnRepeatModeChange -> player.setPlayLooping(event.canRepeat) PlayerEvents.OnMutePlayer -> player.onMuteDevice() - is PlayerEvents.OnSeekPlayer -> player.onSeekDuration(event.amount) + + //seeking the player + is PlayerEvents.OnSeekingPlayer -> viewModelScope.launch { + _trackController.onSliderValueChange(event.amount) + } + + PlayerEvents.OnSeekEndPlayer -> viewModelScope.launch { + _trackController.onInteractionFinished( + onSeekComplete = { player.onSeekDuration(it) }, + ) + } } } From a2b2c0d649ca2c5837ba1b46c2508dda532fc074 Mon Sep 17 00:00:00 2001 From: tuuhin Date: Sun, 28 Dec 2025 11:20:33 +0530 Subject: [PATCH 09/11] Replacing the graph with v2 GraphScrollListener.kt fling need to be disabled for now as its creating inconsistency with the slider Made PlayerAmplitudeGraph.kt as deprecated, although it can be used it's not suggested anymore .idea files are updated somehow!! --- .idea/.gitignore | 3 -- .idea/deploymentTargetSelector.xml | 8 ---- .idea/deviceManager.xml | 13 ------ .idea/inspectionProfiles/Project_Default.xml | 4 -- .idea/misc.xml | 7 +-- .../player_shared/state/PlayerTrackUIState.kt | 4 +- .../composable/AudioPlayerScreenContent.kt | 5 +- .../composable/PlayerAmplitudeGraph.kt | 8 ++++ .../composable/PlayerAmplitudeGraph2.kt | 13 ++++-- .../views/GraphScrollListener.kt | 10 ++-- .../views/PlayerAmplitudeGraph2View.kt | 46 +++++++++++++------ .../feature_player/views/ViewExtensions.kt | 2 +- 12 files changed, 63 insertions(+), 60 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/deviceManager.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 832ac50d..b268ef36 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,14 +4,6 @@ diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 91f95584..00000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 4f8fa0eb..7061a0d6 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -19,19 +19,15 @@