diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index 2da0d25b2af..9848250a08b 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -6704,12 +6704,12 @@ private fun com.wire.android.ui.home.conversations.messages.item.buildContent(is - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.messages.item.buildContent(expandable: kotlin.Boolean, learnMoreLinkResId: kotlin.Int?, learnMoreTextResId: kotlin.Int, iconResId: kotlin.Int, iconTintColor: androidx.compose.ui.graphics.Color?, iconSize: androidx.compose.ui.unit.Dp, additionalVerticalPaddings: androidx.compose.ui.unit.Dp, backgroundColor: androidx.compose.ui.graphics.Color?, annotatedStringBuilder: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \): com.wire.android.ui.home.conversations.messages.item.SystemMessageContent +private fun com.wire.android.ui.home.conversations.messages.item.buildContent(expandable: kotlin.Boolean, learnMorePage: com.wire.android.util.SupportPage?, learnMoreTextResId: kotlin.Int, iconResId: kotlin.Int, iconTintColor: androidx.compose.ui.graphics.Color?, iconSize: androidx.compose.ui.unit.Dp, additionalVerticalPaddings: androidx.compose.ui.unit.Dp, backgroundColor: androidx.compose.ui.graphics.Color?, annotatedStringBuilder: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \): com.wire.android.ui.home.conversations.messages.item.SystemMessageContent skippable: true restartable: true params: - expandable: STABLE (primitive type) - - learnMoreLinkResId: STABLE (class with no mutable properties) + - learnMorePage: STABLE (class with no mutable properties) - learnMoreTextResId: STABLE (primitive type) - iconResId: STABLE (primitive type) - iconTintColor: STABLE (marked @Stable or @Immutable) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 7ef33968584..b230cde2044 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt @@ -28,11 +28,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType @@ -137,7 +139,7 @@ fun AllFilesScreen( ) ) }, - showVideoPlayer = { file -> + showVideoViewer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -150,6 +152,19 @@ fun AllFilesScreen( ) ) }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + AudioPlayerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, fileReadyFlow = viewModel.fileReadyFlow, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 12d220e4b3b..b69eb41ba9e 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt @@ -102,7 +102,8 @@ internal fun CellScreenContent( retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, showImageViewer: (CellNodeUi.File) -> Unit = {}, - showVideoPlayer: (CellNodeUi.File) -> Unit = {}, + showVideoViewer: (CellNodeUi.File) -> Unit = {}, + showAudioPlayer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -260,7 +261,8 @@ internal fun CellScreenContent( ).show() } is OpenImageViewer -> showImageViewer(action.file) - is OpenVideoPlayer -> showVideoPlayer(action.file) + is OpenVideoViewer -> showVideoViewer(action.file) + is OpenAudioPlayer -> showAudioPlayer(action.file) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index e309388fc62..4022303626e 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -377,6 +377,7 @@ class CellViewModel( cancelOpenDownload(uuid) } + @Suppress("ReturnCount") private fun openFileContentUrl(file: CellNodeUi.File) { when (file.assetType) { AttachmentFileType.IMAGE -> { @@ -385,12 +386,14 @@ class CellViewModel( return } } - AttachmentFileType.VIDEO -> { - sendAction(OpenVideoPlayer(file)) + sendAction(OpenVideoViewer(file)) + return + } + AttachmentFileType.AUDIO -> { + sendAction(OpenAudioPlayer(file)) return } - else -> Unit } file.contentUrl?.let { url -> @@ -404,6 +407,7 @@ class CellViewModel( } } + @Suppress("ReturnCount") private fun openLocalFile(file: CellNodeUi.File) { when (file.assetType) { AttachmentFileType.IMAGE -> { @@ -412,12 +416,14 @@ class CellViewModel( return } } - AttachmentFileType.VIDEO -> { - sendAction(OpenVideoPlayer(file)) + sendAction(OpenVideoViewer(file)) + return + } + AttachmentFileType.AUDIO -> { + sendAction(OpenAudioPlayer(file)) return } - else -> Unit } file.localPath?.let { path -> @@ -670,7 +676,8 @@ internal data class OpenFolder(val path: String, val title: String, val parentFo internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction internal data object ShowOfflineFileSaved : CellViewAction internal data class OpenImageViewer(val file: CellNodeUi.File) : CellViewAction -internal data class OpenVideoPlayer(val file: CellNodeUi.File) : CellViewAction +internal data class OpenVideoViewer(val file: CellNodeUi.File) : CellViewAction +internal data class OpenAudioPlayer(val file: CellNodeUi.File) : CellViewAction internal enum class CellError(val message: Int) { NO_APP_FOUND(R.string.no_app_found), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt index cdfd8787e72..913f02531e9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.CreationExtras +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel @@ -130,6 +131,19 @@ object CellsMetroViewModelBindings { ) } + @Provides + @IntoMap + @ViewModelAssistedFactoryKey(AudioPlayerViewModel::class) + fun audioPlayerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = + savedStateViewModel { + factory.cellAudioPlayerViewModel( + context = checkNotNull(it[APPLICATION_KEY]) { + "No Application was provided via CreationExtras" + }, + savedStateHandle = it.createSavedStateHandle(), + ) + } + private fun savedStateViewModel(create: (CreationExtras) -> ViewModel): ViewModelAssistedFactory = object : ViewModelAssistedFactory { override fun create(extras: CreationExtras): ViewModel = create(extras) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt index 4f0b2bc7c39..6b396d9042a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt @@ -19,6 +19,7 @@ package com.wire.android.feature.cells.ui import android.content.Context import androidx.lifecycle.SavedStateHandle +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel import com.wire.android.feature.cells.ui.edit.OnlineEditor @@ -73,7 +74,7 @@ import com.wire.kalium.network.NetworkStateObserver import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Named -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class CellsViewModelFactory @Inject constructor( private val getCellFilesPaged: GetPaginatedFilesFlowUseCase, private val deleteCellAsset: DeleteCellAssetUseCase, @@ -229,4 +230,12 @@ class CellsViewModelFactory @Inject constructor( context = context, savedStateHandle = savedStateHandle, ) + + internal fun cellAudioPlayerViewModel( + context: Context, + savedStateHandle: SavedStateHandle + ) = AudioPlayerViewModel( + context = context, + savedStateHandle = savedStateHandle, + ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt index 45d1125e180..c8dd9b350fa 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import com.wire.android.di.metro.sessionKeyedMetroViewModel +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel @@ -94,3 +95,6 @@ fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() @Composable fun cellVideoViewerViewModel(): VideoPlayerViewModel = cellsViewModel() + +@Composable +fun cellAudioPlayerViewModel(): AudioPlayerViewModel = cellsViewModel() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index decf0a42b77..54569abe705 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt @@ -45,6 +45,7 @@ import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination @@ -58,6 +59,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs @@ -382,7 +384,7 @@ internal fun ConversationFilesScreenContent( ) ) }, - showVideoPlayer = { file -> + showVideoViewer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -395,6 +397,19 @@ internal fun ConversationFilesScreenContent( ) ) }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + AudioPlayerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, onRefresh = onRefresh, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt new file mode 100644 index 00000000000..83cb0dc1194 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.audioplayer + +data class AudioPlaybackState( + val isPlaying: Boolean = false, + val isCompleted: Boolean = false, + val isPrepared: Boolean = false, + val currentPositionMs: Int = 0, + val durationMs: Int = 0, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt new file mode 100644 index 00000000000..47f43aaea93 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.audioplayer + +data class AudioPlayerNavArgs( + val localPath: String? = null, + val contentUrl: String? = null, + val fileName: String? = null, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt new file mode 100644 index 00000000000..6a83150b894 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt @@ -0,0 +1,348 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.audioplayer + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.cellAudioPlayerViewModel +import com.wire.android.navigation.WireNavigator +import com.wire.android.navigation.annotation.features.cells.WireCellsDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import kotlin.time.Duration.Companion.milliseconds + +private const val SKIP_S = 12 +private val SKIP_MS = SKIP_S.milliseconds.inWholeMilliseconds.toInt() + +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = AudioPlayerNavArgs::class, +) +@Composable +fun CellAudioPlayerScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + viewModel: AudioPlayerViewModel = cellAudioPlayerViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + CellAudioPlayerContent( + state = state, + fileName = viewModel.fileName, + onTogglePlayPause = viewModel::togglePlayPause, + onSeek = viewModel::seekTo, + onStop = viewModel::pause, + onNavigateBack = navigator::navigateBack, + modifier = modifier, + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun CellAudioPlayerContent( + state: AudioPlaybackState, + fileName: String?, + onTogglePlayPause: () -> Unit, + onSeek: (Int) -> Unit, + onStop: () -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(0f) } + + fun stopAndBack() { + onStop() + onNavigateBack() + } + + BackHandler { stopAndBack() } + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = ::stopAndBack, + ) + }, + ) { innerPadding -> + + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(color = colorsScheme().background) + .padding(horizontal = dimensions().spacing24x), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + EqualizerBars(isPlaying = state.isPlaying) + } + + val progress = if (state.durationMs > 0 && !isSeeking) { + state.currentPositionMs.toFloat() / state.durationMs + } else if (isSeeking) { + seekProgress + } else { + 0f + } + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Slider( + value = progress, + onValueChange = { value -> + isSeeking = true + seekProgress = value + }, + onValueChangeFinished = { + onSeek((seekProgress * state.durationMs).toInt()) + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = colorsScheme().inverseSurface, + activeTrackColor = colorsScheme().inverseSurface, + inactiveTrackColor = colorsScheme().surfaceContainerHighest, + ), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing4x), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.currentPositionMs.toTimeString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + Text( + text = state.durationMs.toTimeString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + } + + Spacer(modifier = Modifier.height(dimensions().spacing24x)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) + } + ) { + Column { + Icon( + painter = painterResource(R.drawable.ic_skip_back), + contentDescription = null, + tint = colorsScheme().inverseSurface, + modifier = Modifier.size(dimensions().spacing16x), + ) + + Text( + text = SKIP_S.toString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + } + } + + Spacer(modifier = Modifier.width(dimensions().spacing24x)) + + val buttonScale by animateFloatAsState( + targetValue = if (state.isPrepared) 1f else 0.7f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "audioButtonScale", + ) + + Box( + modifier = Modifier + .size(dimensions().spacing64x) + .scale(buttonScale) + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + val iconRes = if (state.isPlaying) R.drawable.ic_pause else R.drawable.ic_play + IconButton( + onClick = { if (state.isPrepared) onTogglePlayPause() }, + modifier = Modifier.fillMaxSize(), + colors = IconButtonDefaults.iconButtonColors( + containerColor = colorsScheme().inverseSurface, + ) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorsScheme().inverseOnSurface, + modifier = Modifier.size(dimensions().spacing40x), + ) + } + } + + Spacer(modifier = Modifier.width(dimensions().spacing24x)) + + IconButton( + onClick = { + onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) + } + ) { + Column { + Icon( + painter = painterResource(R.drawable.ic_skip_forward), + contentDescription = null, + tint = colorsScheme().inverseSurface, + modifier = Modifier.size(dimensions().spacing16x), + ) + Text( + text = SKIP_S.toString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + } + } + } + Spacer(modifier = Modifier.height(dimensions().spacing32x)) + } + } + } +} + +@Composable +private fun EqualizerBars(isPlaying: Boolean) { + val barCount = 7 + val infiniteTransition = rememberInfiniteTransition(label = "equalizerBars") + + val heights = (0 until barCount).map { index -> + val durationMs = 400 + index * 80 + val initialValue = 0.15f + (index % 3) * 0.1f + val targetValue = if (isPlaying) 0.5f + (index % 4) * 0.15f else initialValue + + infiniteTransition.animateFloat( + initialValue = initialValue, + targetValue = targetValue, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = durationMs, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "bar$index", + ) + } + + val maxBarHeightPx = dimensions().spacing24x + Row( + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(maxBarHeightPx), + ) { + heights.forEach { heightState -> + val fraction by heightState + Box( + modifier = Modifier + .width(dimensions().spacing6x) + .height(maxBarHeightPx * fraction) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) + .background(colorsScheme().inverseSurface) + ) + } + } +} + +@Suppress("MagicNumber") +private fun Int.toTimeString(): String { + val totalSec = this / 1000 + val min = totalSec / 60 + val sec = totalSec % 60 + return "%d:%02d".format(min, sec) +} + +@MultipleThemePreviews +@Composable +fun PreviewCellAudioPlayerScreen() { + WireTheme { + CellAudioPlayerContent( + state = AudioPlaybackState(isPlaying = true, isPrepared = true, currentPositionMs = 30000, durationMs = 120000), + fileName = "awesome_track.mp3", + onTogglePlayPause = {}, + onSeek = {}, + onStop = {}, + onNavigateBack = {}, + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt new file mode 100644 index 00000000000..935ad197396 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt @@ -0,0 +1,140 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.audioplayer + +import android.content.Context +import android.media.MediaPlayer +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import java.io.File +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination +import com.wire.android.di.ApplicationContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class AudioPlayerViewModel( + @ApplicationContext context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val navArgs = CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) + val localPath: String? = navArgs.localPath + val contentUrl: String? = navArgs.contentUrl + val fileName: String? = navArgs.fileName + + private val _state = MutableStateFlow(AudioPlaybackState()) + val state: StateFlow = _state.asStateFlow() + + private var positionPollJob: Job? = null + + private val mediaPlayer = MediaPlayer().apply { + setOnPreparedListener { mp -> + _state.update { it.copy(durationMs = mp.duration, isPrepared = true) } + } + setOnCompletionListener { + stopPositionPolling() + _state.update { it.copy(isPlaying = false, isCompleted = true) } + } + } + + init { + try { + audioUri()?.let { uri -> + mediaPlayer.setDataSource(context, uri) + mediaPlayer.prepareAsync() + } + } catch (_: Exception) { + // handle silently — file may not exist yet + } + } + + fun play() { + if (!_state.value.isPrepared) return + mediaPlayer.start() + _state.update { it.copy(isPlaying = true, isCompleted = false) } + startPositionPolling() + } + + fun pause() { + if (!_state.value.isPlaying) return + mediaPlayer.pause() + stopPositionPolling() + _state.update { it.copy(isPlaying = false) } + } + + fun togglePlayPause() { + val current = _state.value + when { + current.isCompleted -> { + seekTo(0) + play() + } + current.isPlaying -> pause() + else -> play() + } + } + + fun seekTo(positionMs: Int) { + mediaPlayer.seekTo(positionMs) + _state.update { it.copy(currentPositionMs = positionMs) } + } + + private fun startPositionPolling() { + if (positionPollJob?.isActive == true) return + positionPollJob = viewModelScope.launch { + while (isActive) { + _state.update { it.copy(currentPositionMs = mediaPlayer.currentPosition) } + delay(POSITION_POLL_MS) + } + } + } + + private fun stopPositionPolling() { + positionPollJob?.cancel() + positionPollJob = null + } + + private fun audioUri(): Uri? = when { + localPath != null -> Uri.fromFile(File(localPath)) + contentUrl != null -> Uri.parse(contentUrl) + else -> null + } + + override fun onCleared() { + super.onCleared() + stopPositionPolling() + try { + mediaPlayer.stop() + } catch (_: Exception) { + // ignore — player may not be in a stoppable state + } + mediaPlayer.release() + } + + private companion object { + const val POSITION_POLL_MS = 200L + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt index 91440c875cf..497b023759c 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/model/CellNodeUi.kt @@ -18,6 +18,7 @@ package com.wire.android.feature.cells.ui.model import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.domain.model.AttachmentFileType.AUDIO import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE import com.wire.android.feature.cells.domain.model.AttachmentFileType.PDF import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO @@ -142,5 +143,5 @@ internal fun CellNodeUi.File.withSessionState( ) internal fun CellNodeUi.File.localFileAvailable() = localPath != null -internal fun CellNodeUi.File.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, PDF) +internal fun CellNodeUi.File.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, AUDIO, PDF) internal fun CellNodeUi.isEditSupported() = (this as? CellNodeUi.File)?.isEditSupported == true diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index b87cfdf85f0..a5a45c6550f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt @@ -40,9 +40,9 @@ import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination.invoke import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination @@ -51,6 +51,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayer import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi @@ -287,7 +288,7 @@ fun SearchScreen( ) ) }, - showVideoPlayer = { file -> + showVideoViewer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -300,6 +301,19 @@ fun SearchScreen( ) ) }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + AudioPlayerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, onRefresh = { }, diff --git a/features/cells/src/main/res/drawable/ic_skip_back.xml b/features/cells/src/main/res/drawable/ic_skip_back.xml new file mode 100644 index 00000000000..8ff5a593f29 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_skip_back.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_skip_forward.xml b/features/cells/src/main/res/drawable/ic_skip_forward.xml new file mode 100644 index 00000000000..71527164058 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_skip_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt new file mode 100644 index 00000000000..bc8a6f18809 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt @@ -0,0 +1,348 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.audioplayer + +import android.content.Context +import android.media.MediaPlayer +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) +class AudioPlayerViewModelTest { + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun givenLocalPath_whenInitialized_thenDataSourceIsSetWithFileUriAndPrepared() = runTest { + val (arrangement, _) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + + verify { Uri.fromFile(any()) } + verify { anyConstructed().setDataSource(arrangement.context, arrangement.fileUri) } + verify { anyConstructed().prepareAsync() } + } + + @Test + fun givenContentUrl_whenInitialized_thenDataSourceIsSetWithParsedUri() = runTest { + val (arrangement, _) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(contentUrl = "https://wire.com/audio.mp3")) + .arrange() + + verify { Uri.parse("https://wire.com/audio.mp3") } + verify { anyConstructed().setDataSource(arrangement.context, arrangement.contentUri) } + verify { anyConstructed().prepareAsync() } + } + + @Test + fun givenNoSource_whenInitialized_thenDataSourceIsNotSet() = runTest { + Arrangement() + .withNavArgs(AudioPlayerNavArgs()) + .arrange() + + verify(exactly = 0) { anyConstructed().setDataSource(any(), any()) } + verify(exactly = 0) { anyConstructed().prepareAsync() } + } + + @Test + fun givenSetDataSourceThrows_whenInitialized_thenExceptionIsHandledSilently() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSetDataSourceThrowing() + .arrange() + + assertFalse(viewModel.state.value.isPrepared) + } + + @Test + fun givenNavArgs_whenInitialized_thenExposedAsProperties() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs( + AudioPlayerNavArgs( + localPath = "/tmp/audio.mp3", + contentUrl = "https://wire.com/audio.mp3", + fileName = "audio.mp3", + ) + ) + .arrange() + + assertEquals("/tmp/audio.mp3", viewModel.localPath) + assertEquals("https://wire.com/audio.mp3", viewModel.contentUrl) + assertEquals("audio.mp3", viewModel.fileName) + } + + @Test + fun givenPlayerPrepares_whenOnPrepared_thenStateHasDurationAndIsPrepared() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withDuration(5000) + .arrange() + + arrangement.triggerPrepared() + + assertEquals(5000, viewModel.state.value.durationMs) + assertTrue(viewModel.state.value.isPrepared) + } + + @Test + fun givenNotPrepared_whenPlay_thenNothingHappens() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + + viewModel.play() + + verify(exactly = 0) { anyConstructed().start() } + assertFalse(viewModel.state.value.isPlaying) + } + + @Test + fun givenPrepared_whenPlay_thenStartsAndUpdatesStateAndPollsPosition() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withCurrentPosition(42) + .arrange() + arrangement.triggerPrepared() + + viewModel.play() + + verify { anyConstructed().start() } + assertTrue(viewModel.state.value.isPlaying) + assertFalse(viewModel.state.value.isCompleted) + assertEquals(42, viewModel.state.value.currentPositionMs) + + arrangement.clear(viewModel) + } + + @Test + fun givenNotPlaying_whenPause_thenNothingHappens() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + + viewModel.pause() + + verify(exactly = 0) { anyConstructed().pause() } + } + + @Test + fun givenPlaying_whenPause_thenPausesAndUpdatesState() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + arrangement.triggerPrepared() + viewModel.play() + + viewModel.pause() + + verify { anyConstructed().pause() } + assertFalse(viewModel.state.value.isPlaying) + } + + @Test + fun givenNotPlaying_whenTogglePlayPause_thenStartsPlaying() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + arrangement.triggerPrepared() + + viewModel.togglePlayPause() + + verify { anyConstructed().start() } + assertTrue(viewModel.state.value.isPlaying) + + arrangement.clear(viewModel) // stop the position-polling loop before runTest drains the scheduler + } + + @Test + fun givenPlaying_whenTogglePlayPause_thenPauses() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + arrangement.triggerPrepared() + viewModel.play() + + viewModel.togglePlayPause() + + verify { anyConstructed().pause() } + assertFalse(viewModel.state.value.isPlaying) + } + + @Test + fun givenCompleted_whenTogglePlayPause_thenSeeksToStartAndPlays() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + arrangement.triggerPrepared() + arrangement.triggerCompletion() + + viewModel.togglePlayPause() + + verify { anyConstructed().seekTo(0) } + verify { anyConstructed().start() } + assertTrue(viewModel.state.value.isPlaying) + assertFalse(viewModel.state.value.isCompleted) + + arrangement.clear(viewModel) // stop the position-polling loop before runTest drains the scheduler + } + + @Test + fun whenSeekTo_thenMediaPlayerSeeksAndStateUpdated() = runTest { + val (_, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + + viewModel.seekTo(1234) + + verify { anyConstructed().seekTo(1234) } + assertEquals(1234, viewModel.state.value.currentPositionMs) + } + + @Test + fun givenPlaying_whenCompletionFires_thenStateIsCompletedAndNotPlaying() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + arrangement.triggerPrepared() + viewModel.play() + + arrangement.triggerCompletion() + + assertFalse(viewModel.state.value.isPlaying) + assertTrue(viewModel.state.value.isCompleted) + } + + @Test + fun whenCleared_thenPlayerIsStoppedAndReleased() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .arrange() + + arrangement.clear(viewModel) + + verify { anyConstructed().stop() } + verify { anyConstructed().release() } + } + + @Test + fun givenStopThrows_whenCleared_thenReleaseIsStillCalled() = runTest { + val (arrangement, viewModel) = Arrangement() + .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withStopThrowing() + .arrange() + + arrangement.clear(viewModel) + + verify { anyConstructed().release() } + } + + private class Arrangement { + + val context = mockk(relaxed = true) + val savedStateHandle = mockk(relaxed = true) + val fileUri = mockk() + val contentUri = mockk() + + private val preparedMp = mockk(relaxed = true) + private val preparedListenerSlot = slot() + private val completionListenerSlot = slot() + + private var navArgs = AudioPlayerNavArgs(localPath = "/tmp/audio.mp3") + + init { + mockkObject(CellAudioPlayerScreenDestination) + + mockkStatic(Uri::class) + every { Uri.fromFile(any()) } returns fileUri + every { Uri.parse(any()) } returns contentUri + + mockkConstructor(MediaPlayer::class) + every { anyConstructed().setOnPreparedListener(capture(preparedListenerSlot)) } just Runs + every { anyConstructed().setOnCompletionListener(capture(completionListenerSlot)) } just Runs + every { anyConstructed().setDataSource(any(), any()) } just Runs + every { anyConstructed().prepareAsync() } just Runs + every { anyConstructed().start() } just Runs + every { anyConstructed().pause() } just Runs + every { anyConstructed().seekTo(any()) } just Runs + every { anyConstructed().stop() } just Runs + every { anyConstructed().release() } just Runs + every { anyConstructed().currentPosition } returns 0 + } + + fun withNavArgs(args: AudioPlayerNavArgs) = apply { navArgs = args } + + fun withDuration(durationMs: Int) = apply { + every { preparedMp.duration } returns durationMs + } + + fun withCurrentPosition(positionMs: Int) = apply { + every { anyConstructed().currentPosition } returns positionMs + } + + fun withSetDataSourceThrowing() = apply { + every { anyConstructed().setDataSource(any(), any()) } throws RuntimeException("boom") + } + + fun withStopThrowing() = apply { + every { anyConstructed().stop() } throws IllegalStateException("boom") + } + + fun triggerPrepared() { + preparedListenerSlot.captured.onPrepared(preparedMp) + } + + fun triggerCompletion() { + completionListenerSlot.captured.onCompletion(preparedMp) + } + + fun clear(viewModel: ViewModel) { + val method = ViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(viewModel) + } + + fun arrange(): Pair { + every { CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) } returns navArgs + return this to AudioPlayerViewModel(context, savedStateHandle) + } + } +} diff --git a/features/cells/stability/cells-debug.stability b/features/cells/stability/cells-debug.stability index aacd67fcafc..fccbb6c2e89 100644 --- a/features/cells/stability/cells-debug.stability +++ b/features/cells/stability/cells-debug.stability @@ -10,6 +10,12 @@ public fun com.ramcosta.composedestinations.generated.cells.destinations.AddRemo restartable: true params: +@Composable +public fun com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination.Content(): kotlin.Unit + skippable: true + restartable: true + params: + @Composable public fun com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination.Content(): kotlin.Unit skippable: true @@ -146,7 +152,7 @@ internal fun com.wire.android.feature.cells.ui.CellListItem(cell: com.wire.andro - showConversationName: STABLE (primitive type) @Composable -internal fun com.wire.android.feature.cells.ui.CellScreenContent(actionsFlow: kotlinx.coroutines.flow.Flow, pagingListItems: androidx.paging.compose.LazyPagingItems, sendIntent: kotlin.Function1, openFolder: kotlin.Function3, menuState: kotlinx.coroutines.flow.Flow, showPublicLinkScreen: kotlin.Function1, showRenameScreen: kotlin.Function1, showMoveToFolderScreen: kotlin.Function3, showAddRemoveTagsScreen: kotlin.Function1, isRefreshing: androidx.compose.runtime.State, onRefresh: kotlin.Function0, isRestoreInProgress: kotlin.Boolean, isDeleteInProgress: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, isRecycleBin: kotlin.Boolean, isAllFiles: kotlin.Boolean, isSearchResult: kotlin.Boolean, isOffline: kotlin.Boolean, isPullToRefreshEnabled: kotlin.Boolean, lazyListState: androidx.compose.foundation.lazy.LazyListState, retryEditNodeError: kotlin.Function1, showVersionHistoryScreen: kotlin.Function2, showImageViewer: kotlin.Function1, showVideoPlayer: kotlin.Function1, fileReadyFlow: kotlinx.coroutines.flow.Flow?): kotlin.Unit +internal fun com.wire.android.feature.cells.ui.CellScreenContent(actionsFlow: kotlinx.coroutines.flow.Flow, pagingListItems: androidx.paging.compose.LazyPagingItems, sendIntent: kotlin.Function1, openFolder: kotlin.Function3, menuState: kotlinx.coroutines.flow.Flow, showPublicLinkScreen: kotlin.Function1, showRenameScreen: kotlin.Function1, showMoveToFolderScreen: kotlin.Function3, showAddRemoveTagsScreen: kotlin.Function1, isRefreshing: androidx.compose.runtime.State, onRefresh: kotlin.Function0, isRestoreInProgress: kotlin.Boolean, isDeleteInProgress: kotlin.Boolean, modifier: androidx.compose.ui.Modifier, isRecycleBin: kotlin.Boolean, isAllFiles: kotlin.Boolean, isSearchResult: kotlin.Boolean, isOffline: kotlin.Boolean, isPullToRefreshEnabled: kotlin.Boolean, lazyListState: androidx.compose.foundation.lazy.LazyListState, retryEditNodeError: kotlin.Function1, showVersionHistoryScreen: kotlin.Function2, showImageViewer: kotlin.Function1, showVideoViewer: kotlin.Function1, showAudioPlayer: kotlin.Function1, fileReadyFlow: kotlinx.coroutines.flow.Flow?): kotlin.Unit skippable: false restartable: true params: @@ -173,7 +179,8 @@ internal fun com.wire.android.feature.cells.ui.CellScreenContent(actionsFlow: ko - retryEditNodeError: STABLE (function type) - showVersionHistoryScreen: STABLE (function type) - showImageViewer: STABLE (function type) - - showVideoPlayer: STABLE (function type) + - showVideoViewer: STABLE (function type) + - showAudioPlayer: STABLE (function type) - fileReadyFlow: RUNTIME (requires runtime check) @Composable @@ -271,12 +278,12 @@ internal fun com.wire.android.feature.cells.ui.FolderIconPreview(cell: com.wire. - cell: RUNTIME (requires runtime check) @Composable -private fun com.wire.android.feature.cells.ui.LearnMoreLink(textRes: kotlin.Int, urlRes: kotlin.Int): kotlin.Unit +private fun com.wire.android.feature.cells.ui.LearnMoreLink(textRes: kotlin.Int, page: com.wire.android.util.SupportPage): kotlin.Unit skippable: true restartable: true params: - textRes: STABLE (primitive type) - - urlRes: STABLE (primitive type) + - page: STABLE (class with no mutable properties) @Composable internal fun com.wire.android.feature.cells.ui.LoadingIconPreview(progress: kotlin.Float?): kotlin.Unit @@ -305,6 +312,41 @@ public fun com.wire.android.feature.cells.ui.addRemoveTagsViewModel(): com.wire. restartable: true params: +@Composable +internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerContent(state: com.wire.android.feature.cells.ui.audioplayer.AudioPlaybackState, fileName: kotlin.String?, onTogglePlayPause: kotlin.Function0, onSeek: kotlin.Function1, onStop: kotlin.Function0, onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit + skippable: true + restartable: true + params: + - state: STABLE (class with no mutable properties) + - fileName: STABLE (class with no mutable properties) + - onTogglePlayPause: STABLE (function type) + - onSeek: STABLE (function type) + - onStop: STABLE (function type) + - onNavigateBack: STABLE (function type) + - modifier: STABLE (marked @Stable or @Immutable) + +@Composable +public fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerScreen(navigator: com.wire.android.navigation.WireNavigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel): kotlin.Unit + skippable: false + restartable: true + params: + - navigator: STABLE (marked @Stable or @Immutable) + - modifier: STABLE (marked @Stable or @Immutable) + - viewModel: UNSTABLE (has mutable properties or unstable members) + +@Composable +private fun com.wire.android.feature.cells.ui.audioplayer.EqualizerBars(isPlaying: kotlin.Boolean): kotlin.Unit + skippable: true + restartable: true + params: + - isPlaying: STABLE (primitive type) + +@Composable +public fun com.wire.android.feature.cells.ui.cellAudioPlayerViewModel(): com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel + skippable: true + restartable: true + params: + @Composable public fun com.wire.android.feature.cells.ui.cellImageViewerViewModel(): com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel skippable: true