From 5ff36d608c09cf85bf0d43a1923e112b9db4cb43 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 19:46:44 +0200 Subject: [PATCH 01/18] feat: In-app image viewer --- .../feature/cells/ui/AllFilesScreen.kt | 17 ++ .../feature/cells/ui/CellScreenContent.kt | 2 + .../android/feature/cells/ui/CellViewModel.kt | 10 + .../cells/ui/ConversationFilesScreen.kt | 17 ++ .../ui/imageviewer/CellImageViewerNavArgs.kt | 27 +++ .../ui/imageviewer/CellImageViewerScreen.kt | 191 ++++++++++++++++++ .../imageviewer/CellImageViewerViewModel.kt | 39 ++++ .../cells/ui/recyclebin/RecycleBinScreen.kt | 17 ++ .../feature/cells/ui/search/SearchScreen.kt | 17 ++ .../cells/src/main/res/values/strings.xml | 1 + .../feature/cells/ui/CellViewModelTest.kt | 48 ++++- kalium | 2 +- 12 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerNavArgs.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt 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 3dc9c3ec67f..fc251a8eb0f 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 @@ -27,9 +27,11 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination +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.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator @@ -106,6 +108,21 @@ fun AllFilesScreen( }, isRefreshing = viewModel.isPullToRefresh.collectAsState(), onRefresh = { viewModel.onPullToRefresh() }, + showImageViewer = { file -> + navigator.navigate( + NavigationCommand( + CellImageViewerScreenDestination( + CellImageViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + previewUrl = file.previewUrl, + contentHash = file.contentHash, + 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 96ffe31f2b3..229ec25e038 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 @@ -98,6 +98,7 @@ internal fun CellScreenContent( lazyListState: LazyListState = rememberLazyListState(), retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, + showImageViewer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -245,6 +246,7 @@ internal fun CellScreenContent( is ShowFileDeletedMessage -> showDeleteConfirmation(context, action.isFile, action.permanently) is OpenFolder -> openFolder(action.path, action.title, action.parentFolderUuid) is ShowEditErrorDialog -> editNodeError = action.nodeUuid + is OpenImageViewer -> showImageViewer(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 5171fd7c61c..5560db21599 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 @@ -32,6 +32,7 @@ import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.NodeBottomSheetAction import com.wire.android.feature.cells.ui.model.OpenLoadState +import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.model.canOpenWithUrl import com.wire.android.feature.cells.ui.model.localFileAvailable import com.wire.android.feature.cells.ui.model.toUiModel @@ -309,6 +310,10 @@ class CellViewModel @Inject constructor( } private fun openFileContentUrl(file: CellNodeUi.File) { + if (file.assetType == AttachmentFileType.IMAGE) { + sendAction(OpenImageViewer(file)) + return + } file.contentUrl?.let { url -> fileHelper.openAssetUrlWithExternalApp( url = url, @@ -321,6 +326,10 @@ class CellViewModel @Inject constructor( } private fun openLocalFile(file: CellNodeUi.File) { + if (file.assetType == AttachmentFileType.IMAGE) { + sendAction(OpenImageViewer(file)) + return + } file.localPath?.let { path -> fileHelper.openAssetFileWithExternalApp( localPath = path.toPath(), @@ -500,6 +509,7 @@ internal data class ShowFileDeletedMessage(val isFile: Boolean, val permanently: internal data object RefreshData : CellViewAction internal data class OpenFolder(val path: String, val title: String, val parentFolderUuid: String?) : CellViewAction internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction +internal data class OpenImageViewer(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/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index 3cba2524e53..e921a6d9582 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.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination @@ -56,6 +57,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet @@ -350,6 +352,21 @@ internal fun ConversationFilesScreenContent( showVersionHistoryScreen = { uuid, fileName -> navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName))) }, + showImageViewer = { file -> + navigator.navigate( + NavigationCommand( + CellImageViewerScreenDestination( + CellImageViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + previewUrl = file.previewUrl, + contentHash = file.contentHash, + fileName = file.name, + ) + ) + ) + ) + }, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, onRefresh = onRefresh, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerNavArgs.kt new file mode 100644 index 00000000000..fa552522d7b --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerNavArgs.kt @@ -0,0 +1,27 @@ +/* + * 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.imageviewer + +data class CellImageViewerNavArgs( + val localPath: String? = null, + val contentUrl: String? = null, + val previewUrl: String? = null, + val contentHash: String? = null, + val fileName: String? = null, +) + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt new file mode 100644 index 00000000000..0e4f2885782 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerScreen.kt @@ -0,0 +1,191 @@ +/* + * 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.imageviewer + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.rememberAsyncImagePainter +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.wire.android.feature.cells.R +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.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.theme.WireTheme + +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = CellImageViewerNavArgs::class, +) +@Composable +fun CellImageViewerScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + viewModel: CellImageViewerViewModel = hiltViewModel(), +) { + CellImageViewerScreenContent( + localPath = viewModel.localPath, + contentUrl = viewModel.contentUrl, + previewUrl = viewModel.previewUrl, + contentHash = viewModel.contentHash, + fileName = viewModel.fileName, + onNavigateBack = navigator::navigateBack, + modifier = modifier, + ) +} + +@Composable +internal fun CellImageViewerScreenContent( + localPath: String?, + contentUrl: String?, + previewUrl: String?, + contentHash: String?, + fileName: String?, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = onNavigateBack, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + CellZoomableImage( + localPath = localPath, + contentUrl = contentUrl, + previewUrl = previewUrl, + contentHash = contentHash, + contentDescription = fileName ?: stringResource(R.string.content_description_image_message), + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun CellZoomableImage( + localPath: String?, + contentUrl: String?, + previewUrl: String?, + contentHash: String?, + contentDescription: String, + modifier: Modifier = Modifier, +) { + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var zoom by remember { mutableStateOf(1f) } + val minScale = 1.0f + val maxScale = 3f + + val painter = when { + localPath != null -> rememberAsyncImagePainter(localPath) + contentUrl != null -> rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(contentUrl) + .diskCacheKey(contentHash) + .memoryCacheKey(contentHash) + .diskCachePolicy(CachePolicy.ENABLED) + .build(), + placeholder = previewUrl?.let { + rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(it) + .diskCacheKey(contentHash) + .memoryCacheKey(contentHash) + .crossfade(true) + .build() + ) + }, + ) + else -> return + } + + Image( + painter = painter, + contentDescription = contentDescription, + modifier = modifier + .graphicsLayer( + scaleX = zoom, + scaleY = zoom, + translationX = offsetX, + translationY = offsetY, + ) + .pointerInput(Unit) { + detectTransformGestures { _, pan, gestureZoom, _ -> + zoom = (zoom * gestureZoom).coerceIn(minScale, maxScale) + if (zoom > 1) { + offsetX += pan.x * zoom + offsetY += pan.y * zoom + } else { + offsetX = 0f + offsetY = 0f + } + } + }, + contentScale = ContentScale.Fit, + ) +} + +@MultipleThemePreviews +@Composable +fun PreviewCellImageViewerScreen() { + WireTheme { + CellImageViewerScreenContent( + localPath = null, + contentUrl = null, + previewUrl = null, + contentHash = null, + fileName = "photo.jpg", + onNavigateBack = {}, + ) + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt new file mode 100644 index 00000000000..26cd17776ce --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/imageviewer/CellImageViewerViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.imageviewer + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CellImageViewerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val navArgs: CellImageViewerNavArgs = CellImageViewerScreenDestination.argsFrom(savedStateHandle) + + val localPath: String? = navArgs.localPath + val contentUrl: String? = navArgs.contentUrl + val previewUrl: String? = navArgs.previewUrl + val contentHash: String? = navArgs.contentHash + val fileName: String? = navArgs.fileName +} + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt index 0f0d45e3ed8..bff78919dec 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/recyclebin/RecycleBinScreen.kt @@ -34,9 +34,11 @@ import com.wire.android.feature.cells.ui.CellFilesNavArgs import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel import com.wire.android.feature.cells.ui.common.Breadcrumbs +import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator @@ -139,6 +141,21 @@ fun RecycleBinScreen( }, showRenameScreen = { }, showAddRemoveTagsScreen = {}, + showImageViewer = { file -> + navigator.navigate( + NavigationCommand( + CellImageViewerScreenDestination( + CellImageViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + previewUrl = file.previewUrl, + contentHash = file.contentHash, + fileName = file.name, + ) + ) + ) + ) + }, isRefreshing = cellViewModel.isPullToRefresh.collectAsState(), onRefresh = { cellViewModel.onPullToRefresh() } ) 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 fcdbd4af8a4..bf860e85994 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,6 +40,7 @@ import androidx.hilt.navigation.compose.hiltViewModel 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.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination @@ -47,6 +48,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist 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.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet @@ -233,6 +235,21 @@ fun SearchScreen( showVersionHistoryScreen = { uuid, fileName -> navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName))) }, + showImageViewer = { file -> + navigator.navigate( + NavigationCommand( + CellImageViewerScreenDestination( + CellImageViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + previewUrl = file.previewUrl, + contentHash = file.contentHash, + fileName = file.name, + ) + ) + ) + ) + }, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, onRefresh = { }, diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index 29c5cce3a2f..adb7b1e4d7c 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -250,4 +250,5 @@ Z to A Smallest first Largest first + Image diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index ba82692c7ee..4f6ac9a25ed 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -25,6 +25,7 @@ import androidx.paging.testing.asSnapshot import app.cash.turbine.test import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination import com.wire.android.config.NavigationTestExtension +import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.model.toUiModel @@ -118,23 +119,61 @@ class CellViewModelTest { } @Test - fun `given view model when file clicked and local file is present file is opened`() = runTest { + fun `given view model when image file clicked and local file is present then in-app viewer is opened`() = runTest { val (arrangement, viewModel) = Arrangement() .withLoadSuccess() .arrange() - viewModel.sendIntent(CellViewIntent.OnItemClick(testFiles[0].toUiModel())) + viewModel.actions.test { + viewModel.sendIntent(CellViewIntent.OnItemClick(testFiles[0].toUiModel())) + + val action = awaitItem() + assert(action is OpenImageViewer) + coVerify(exactly = 0) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } + } + } + + @Test + fun `given view model when non-image file clicked and local file is present then external app is opened`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .arrange() + + val nonImageFile = testFiles[0].copy(mimeType = "application/pdf").toUiModel() + + viewModel.sendIntent(CellViewIntent.OnItemClick(nonImageFile)) coVerify(exactly = 1) { arrangement.fileHelper.openAssetFileWithExternalApp(any(), any(), any(), any()) } } @Test - fun `given view model when file clicked and local file is not present and url is openable then url is opened`() = runTest { + fun `given view model when image file clicked and local file is not present and url is openable then in-app viewer is opened`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withLoadSuccess() + .arrange() + + val testFile = testFiles[0].copy( + localPath = null, + contentUrl = "https://example.com/file" + ) + + viewModel.actions.test { + viewModel.sendIntent(CellViewIntent.OnItemClick(testFile.toUiModel())) + + val action = awaitItem() + assert(action is OpenImageViewer) + coVerify(exactly = 0) { arrangement.fileHelper.openAssetUrlWithExternalApp(any(), any(), any()) } + } + } + + @Test + fun `given view model when non-image file clicked and local file is not present and url is openable then url is opened`() = runTest { val (arrangement, viewModel) = Arrangement() .withLoadSuccess() .arrange() val testFile = testFiles[0].copy( + mimeType = "application/pdf", localPath = null, contentUrl = "https://example.com/file" ) @@ -171,7 +210,8 @@ class CellViewModelTest { .arrange() // File has localPath from DB but also carries an error state (stale UI state) - val testFile = testFiles[0].copy(localPath = "localPath", contentUrl = null).toUiModel() + // Use a non-image file so we can verify the external app opener is called + val testFile = testFiles[0].copy(localPath = "localPath", contentUrl = null, mimeType = "application/pdf").toUiModel() .copy(openLoadState = OpenLoadState.Error) viewModel.sendIntent(CellViewIntent.OnItemClick(testFile)) diff --git a/kalium b/kalium index f69a608d512..541dc48c2ee 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit f69a608d5126385b2a1284845657c0bbf11f9c65 +Subproject commit 541dc48c2eefc799199b835388293367d4631ee2 From 6c99dd7f143f448690fe42398c56b107c23ba011 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 9 Jun 2026 14:07:03 +0100 Subject: [PATCH 02/18] feat: In-app video player --- .../feature/cells/ui/AllFilesScreen.kt | 30 + .../feature/cells/ui/CellScreenContent.kt | 4 + .../android/feature/cells/ui/CellViewModel.kt | 36 +- .../cells/ui/ConversationFilesScreen.kt | 30 + .../ui/audioplayer/CellAudioPlayerNavArgs.kt | 25 + .../ui/audioplayer/CellAudioPlayerScreen.kt | 521 ++++++++++++++++++ .../audioplayer/CellAudioPlayerViewModel.kt | 35 ++ .../feature/cells/ui/search/SearchScreen.kt | 30 + .../ui/videoviewer/CellVideoViewerNavArgs.kt | 25 + .../ui/videoviewer/CellVideoViewerScreen.kt | 447 +++++++++++++++ .../videoviewer/CellVideoViewerViewModel.kt | 37 ++ .../src/main/res/drawable/ic_cell_pause.xml | 28 + .../src/main/res/drawable/ic_cell_play.xml | 28 + .../src/main/res/drawable/ic_cell_replay.xml | 28 + .../main/res/drawable/ic_cell_skip_back.xml | 33 ++ .../res/drawable/ic_cell_skip_forward.xml | 33 ++ 16 files changed, 1364 insertions(+), 6 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt create mode 100644 features/cells/src/main/res/drawable/ic_cell_pause.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_play.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_replay.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_skip_back.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_skip_forward.xml 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 fc251a8eb0f..3bc3e920813 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 @@ -27,11 +27,15 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel 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.CellVideoViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator @@ -123,6 +127,32 @@ fun AllFilesScreen( ) ) }, + showVideoViewer = { file -> + navigator.navigate( + NavigationCommand( + CellVideoViewerScreenDestination( + CellVideoViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + CellAudioPlayerNavArgs( + 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 229ec25e038..cabd2b42ec5 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 @@ -99,6 +99,8 @@ internal fun CellScreenContent( retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, showImageViewer: (CellNodeUi.File) -> Unit = {}, + showVideoViewer: (CellNodeUi.File) -> Unit = {}, + showAudioPlayer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -247,6 +249,8 @@ internal fun CellScreenContent( is OpenFolder -> openFolder(action.path, action.title, action.parentFolderUuid) is ShowEditErrorDialog -> editNodeError = action.nodeUuid is OpenImageViewer -> showImageViewer(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 5560db21599..ba94aeaa7a2 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 @@ -310,9 +310,20 @@ class CellViewModel @Inject constructor( } private fun openFileContentUrl(file: CellNodeUi.File) { - if (file.assetType == AttachmentFileType.IMAGE) { - sendAction(OpenImageViewer(file)) - return + when (file.assetType) { + AttachmentFileType.IMAGE -> { + sendAction(OpenImageViewer(file)) + return + } + AttachmentFileType.VIDEO -> { + sendAction(OpenVideoViewer(file)) + return + } + AttachmentFileType.AUDIO -> { + sendAction(OpenAudioPlayer(file)) + return + } + else -> Unit } file.contentUrl?.let { url -> fileHelper.openAssetUrlWithExternalApp( @@ -326,9 +337,20 @@ class CellViewModel @Inject constructor( } private fun openLocalFile(file: CellNodeUi.File) { - if (file.assetType == AttachmentFileType.IMAGE) { - sendAction(OpenImageViewer(file)) - return + when (file.assetType) { + AttachmentFileType.IMAGE -> { + sendAction(OpenImageViewer(file)) + return + } + AttachmentFileType.VIDEO -> { + sendAction(OpenVideoViewer(file)) + return + } + AttachmentFileType.AUDIO -> { + sendAction(OpenAudioPlayer(file)) + return + } + else -> Unit } file.localPath?.let { path -> fileHelper.openAssetFileWithExternalApp( @@ -510,6 +532,8 @@ internal data object RefreshData : CellViewAction internal data class OpenFolder(val path: String, val title: String, val parentFolderUuid: String?) : CellViewAction internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction internal data class OpenImageViewer(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/ConversationFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/ConversationFilesScreen.kt index e921a6d9582..171d271356e 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,7 +45,9 @@ 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.CellVideoViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination @@ -57,7 +59,9 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet @@ -367,6 +371,32 @@ internal fun ConversationFilesScreenContent( ) ) }, + showVideoViewer = { file -> + navigator.navigate( + NavigationCommand( + CellVideoViewerScreenDestination( + CellVideoViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + CellAudioPlayerNavArgs( + 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/CellAudioPlayerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt new file mode 100644 index 00000000000..bc35d61bac4 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt @@ -0,0 +1,25 @@ +/* + * 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 CellAudioPlayerNavArgs( + 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/CellAudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt new file mode 100644 index 00000000000..6226c9cbdeb --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt @@ -0,0 +1,521 @@ +/* + * 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.media.MediaPlayer +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.FastOutSlowInEasing +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.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.wire.android.feature.cells.R +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.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.theme.WireTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +private const val POSITION_POLL_MS = 200L +private const val SKIP_MS = 15_000 + +private val BackgroundTop = Color(0xFF1A1A2E) +private val BackgroundBottom = Color(0xFF0D0D1A) +private val AccentColor = Color(0xFF6C5CE7) +private val AccentLight = Color(0xFFA29BFE) + +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = CellAudioPlayerNavArgs::class, +) +@Composable +fun CellAudioPlayerScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + viewModel: CellAudioPlayerViewModel = hiltViewModel(), +) { + CellAudioPlayerContent( + localPath = viewModel.localPath, + contentUrl = viewModel.contentUrl, + fileName = viewModel.fileName, + onNavigateBack = navigator::navigateBack, + modifier = modifier, + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun CellAudioPlayerContent( + localPath: String?, + contentUrl: String?, + fileName: String?, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + var isPlaying by remember { mutableStateOf(false) } + var isCompleted by remember { mutableStateOf(false) } + var currentPositionMs by remember { mutableIntStateOf(0) } + var durationMs by remember { mutableIntStateOf(0) } + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(0f) } + var isPrepared by remember { mutableStateOf(false) } + + val mediaPlayer = remember { + MediaPlayer().apply { + setOnPreparedListener { mp -> + durationMs = mp.duration + isPrepared = true + } + setOnCompletionListener { + isPlaying = false + isCompleted = true + } + } + } + + // Initialise the media source + LaunchedEffect(localPath, contentUrl) { + try { + mediaPlayer.reset() + isPrepared = false + when { + localPath != null -> mediaPlayer.setDataSource(localPath) + contentUrl != null -> mediaPlayer.setDataSource(context, Uri.parse(contentUrl)) + else -> return@LaunchedEffect + } + mediaPlayer.prepareAsync() + } catch (_: Exception) { + // handle silently — file may not exist yet + } + } + + // Poll playback position while playing + LaunchedEffect(isPlaying) { + while (isActive && isPlaying) { + if (!isSeeking) { + currentPositionMs = mediaPlayer.currentPosition + } + delay(POSITION_POLL_MS) + } + } + + // Cleanup + DisposableEffect(Unit) { + onDispose { + try { mediaPlayer.stop() } catch (_: Exception) { } + mediaPlayer.release() + } + } + + fun stopAndBack() { + try { mediaPlayer.stop() } catch (_: Exception) { } + onNavigateBack() + } + + fun play() { + if (isPrepared) { + mediaPlayer.start() + isPlaying = true + isCompleted = false + } + } + + fun pause() { + mediaPlayer.pause() + isPlaying = false + } + + fun seekTo(ms: Int) { + mediaPlayer.seekTo(ms) + currentPositionMs = ms + } + + fun togglePlayPause() { + if (isCompleted) { + seekTo(0) + play() + } else if (isPlaying) { + pause() + } else { + play() + } + } + + BackHandler { stopAndBack() } + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = ::stopAndBack, + ) + }, + ) { innerPadding -> + + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf(BackgroundTop, BackgroundBottom) + ) + ), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing24x), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + Spacer(modifier = Modifier.height(dimensions().spacing24x)) + + // — Animated album art circle + PulsingAlbumArt(isPlaying = isPlaying) + + Spacer(modifier = Modifier.height(dimensions().spacing32x)) + + // — Equalizer bars + EqualizerBars(isPlaying = isPlaying) + + Spacer(modifier = Modifier.height(dimensions().spacing24x)) + + // — File name + Text( + text = fileName ?: stringResource(R.string.conversation_files_title), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(dimensions().spacing32x)) + + // — Seek slider + val progress = if (durationMs > 0 && !isSeeking) { + currentPositionMs.toFloat() / durationMs + } else if (isSeeking) { + seekProgress + } else { + 0f + } + + Slider( + value = progress, + onValueChange = { value -> + isSeeking = true + seekProgress = value + }, + onValueChangeFinished = { + val targetMs = (seekProgress * durationMs).toInt() + seekTo(targetMs) + currentPositionMs = targetMs + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = AccentLight, + activeTrackColor = AccentLight, + inactiveTrackColor = Color.White.copy(alpha = 0.25f), + ), + modifier = Modifier.fillMaxWidth(), + ) + + // — Time row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing4x), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = currentPositionMs.toTimeString(), + color = Color.White.copy(alpha = 0.8f), + fontSize = 12.sp, + ) + Text( + text = durationMs.toTimeString(), + color = Color.White.copy(alpha = 0.5f), + fontSize = 12.sp, + ) + } + + Spacer(modifier = Modifier.height(dimensions().spacing24x)) + + // — Controls row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // Skip back 15s + IconButton( + onClick = { + val target = (currentPositionMs - SKIP_MS).coerceAtLeast(0) + seekTo(target) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_cell_skip_back), + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(32.dp), + ) + } + + Spacer(modifier = Modifier.width(dimensions().spacing24x)) + + // Play / Pause button (large, spring-animated) + val buttonScale by animateFloatAsState( + targetValue = if (isPrepared) 1f else 0.7f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "audioButtonScale", + ) + + Box( + modifier = Modifier + .size(72.dp) + .scale(buttonScale) + .clip(CircleShape) + .background(AccentColor) + .then( + if (isPrepared) { + Modifier.padding(0.dp) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + val iconRes = if (isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play + IconButton( + onClick = { if (isPrepared) togglePlayPause() }, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } + } + + Spacer(modifier = Modifier.width(dimensions().spacing24x)) + + // Skip forward 15s + IconButton( + onClick = { + val target = (currentPositionMs + SKIP_MS).coerceAtMost(durationMs) + seekTo(target) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_cell_skip_forward), + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(32.dp), + ) + } + } + + Spacer(modifier = Modifier.height(dimensions().spacing32x)) + } + } + } +} + +// — Animated pulsing album art placeholder +@Composable +private fun PulsingAlbumArt(isPlaying: Boolean) { + val infiniteTransition = rememberInfiniteTransition(label = "albumArtPulse") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = if (isPlaying) 1.06f else 1f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "albumArtScale", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(200.dp) + .scale(scale) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf(AccentColor, BackgroundTop), + ) + ), + ) { + // Outer glow ring + Box( + modifier = Modifier + .size(190.dp) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + AccentLight.copy(alpha = 0.15f), + Color.Transparent, + ) + ) + ) + ) + Icon( + painter = painterResource(R.drawable.ic_file_type_audio), + contentDescription = null, + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(88.dp), + ) + } +} + +// — Animated equalizer bars +@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 = 32.dp + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(maxBarHeightPx), + ) { + heights.forEach { heightState -> + val fraction by heightState + Box( + modifier = Modifier + .width(5.dp) + .height(maxBarHeightPx * fraction) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(3.dp)) + .background( + Brush.verticalGradient( + colors = listOf(AccentLight, AccentColor) + ) + ) + ) + } + } +} + +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( + localPath = null, + contentUrl = null, + fileName = "awesome_track.mp3", + onNavigateBack = {}, + ) + } +} + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt new file mode 100644 index 00000000000..18bce3ea462 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt @@ -0,0 +1,35 @@ +/* + * 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.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CellAudioPlayerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val navArgs = CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) + val localPath: String? = navArgs.localPath + val contentUrl: String? = navArgs.contentUrl + val fileName: String? = navArgs.fileName +} + 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 bf860e85994..ea7560461e5 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,7 +40,9 @@ import androidx.hilt.navigation.compose.hiltViewModel 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.CellVideoViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination @@ -48,8 +50,10 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist 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.CellAudioPlayerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet import com.wire.android.feature.cells.ui.search.filter.bottomsheet.conversation.FilterByConversationBottomSheet @@ -250,6 +254,32 @@ fun SearchScreen( ) ) }, + showVideoViewer = { file -> + navigator.navigate( + NavigationCommand( + CellVideoViewerScreenDestination( + CellVideoViewerNavArgs( + localPath = file.localPath, + contentUrl = file.contentUrl, + fileName = file.name, + ) + ) + ) + ) + }, + showAudioPlayer = { file -> + navigator.navigate( + NavigationCommand( + CellAudioPlayerScreenDestination( + CellAudioPlayerNavArgs( + 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/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt new file mode 100644 index 00000000000..e3e01124ffd --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt @@ -0,0 +1,25 @@ +/* + * 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.videoviewer + +data class CellVideoViewerNavArgs( + 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/videoviewer/CellVideoViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt new file mode 100644 index 00000000000..167db68cd96 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt @@ -0,0 +1,447 @@ +/* + * 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.videoviewer + +import android.net.Uri +import android.widget.VideoView +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.video.VideoFrameDecoder +import com.wire.android.feature.cells.R +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.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.theme.WireTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.File + +private const val CONTROLS_AUTO_HIDE_MS = 3_000L +private const val POSITION_POLL_MS = 200L + +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = CellVideoViewerNavArgs::class, +) +@Composable +fun CellVideoViewerScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + viewModel: CellVideoViewerViewModel = hiltViewModel(), +) { + CellVideoViewerScreenContent( + localPath = viewModel.localPath, + contentUrl = viewModel.contentUrl, + fileName = viewModel.fileName, + onNavigateBack = navigator::navigateBack, + modifier = modifier, + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun CellVideoViewerScreenContent( + localPath: String?, + contentUrl: String?, + fileName: String?, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Playback state + var isPlaying by remember { mutableStateOf(false) } + var isStarted by remember { mutableStateOf(false) } + var isCompleted by remember { mutableStateOf(false) } + var controlsVisible by remember { mutableStateOf(true) } + var currentPositionMs by remember { mutableIntStateOf(0) } + var durationMs by remember { mutableIntStateOf(0) } + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(0f) } + + var videoViewRef by remember { mutableStateOf(null) } + var autoHideJob by remember { mutableStateOf(null) } + + fun scheduleAutoHide() { + autoHideJob?.cancel() + autoHideJob = scope.launch { + delay(CONTROLS_AUTO_HIDE_MS) + controlsVisible = false + } + } + + fun showControls(autoHide: Boolean = isPlaying) { + controlsVisible = true + if (autoHide) scheduleAutoHide() + } + + fun toggleControls() { + if (controlsVisible) { + if (isPlaying) { + autoHideJob?.cancel() + controlsVisible = false + } + } else { + showControls() + } + } + + fun play() { + videoViewRef?.start() + isPlaying = true + isStarted = true + isCompleted = false + scheduleAutoHide() + } + + fun pause() { + videoViewRef?.pause() + isPlaying = false + autoHideJob?.cancel() + showControls(autoHide = false) + } + + fun replay() { + videoViewRef?.seekTo(0) + play() + } + + fun togglePlayPause() { + if (isCompleted) { + replay() + } else if (isPlaying) { + pause() + } else { + play() + } + } + + // Build video URI + val videoUri = remember(localPath, contentUrl) { + when { + localPath != null -> Uri.fromFile(File(localPath)) + contentUrl != null -> Uri.parse(contentUrl) + else -> null + } + } + + // Poll playback position while playing + LaunchedEffect(isPlaying) { + while (isActive && isPlaying) { + val vv = videoViewRef + if (vv != null && !isSeeking) { + currentPositionMs = vv.currentPosition + durationMs = vv.duration.coerceAtLeast(0) + } + delay(POSITION_POLL_MS) + } + } + + // Cleanup + DisposableEffect(Unit) { + onDispose { + autoHideJob?.cancel() + videoViewRef?.stopPlayback() + } + } + + // Stop playback immediately on back so the video doesn't play during the exit animation + fun stopAndNavigateBack() { + videoViewRef?.stopPlayback() + isPlaying = false + onNavigateBack() + } + + BackHandler { stopAndNavigateBack() } + + WireScaffold( + modifier = modifier, + topBar = { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = ::stopAndNavigateBack, + ) + }, + ) { innerPadding -> + + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background(Color.Black) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { toggleControls() }, + contentAlignment = Alignment.Center, + ) { + if (videoUri != null) { + AndroidView( + factory = { context -> + VideoView(context).apply { + setVideoURI(videoUri) + setOnPreparedListener { mp -> + durationMs = mp.duration + } + setOnCompletionListener { + isPlaying = false + isCompleted = true + controlsVisible = true + autoHideJob?.cancel() + } + }.also { videoViewRef = it } + }, + modifier = Modifier.fillMaxSize(), + onRelease = { + videoViewRef?.stopPlayback() + videoViewRef?.suspend() + }, + ) + } + + // — Thumbnail overlay (fade out on first play) + AnimatedVisibility( + visible = !isStarted, + exit = fadeOut(animationSpec = tween(durationMillis = 600)), + ) { + if (localPath != null) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(localPath) + .decoderFactory { result, options, _ -> + VideoFrameDecoder(result.source, options) + } + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize(), + ) + } + } + + // — Center play / pause / replay button + val buttonScale by animateFloatAsState( + targetValue = if (controlsVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "videoButtonScale", + ) + + Box( + modifier = Modifier + .size(72.dp) + .scale(buttonScale) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { togglePlayPause() }, + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = when { + isCompleted -> VideoButtonState.REPLAY + isPlaying -> VideoButtonState.PAUSE + else -> VideoButtonState.PLAY + }, + transitionSpec = { + (scaleIn(animationSpec = spring(Spring.DampingRatioLowBouncy)) + fadeIn()) togetherWith + (scaleOut() + fadeOut()) + }, + label = "videoButtonIcon", + ) { state -> + val res = when (state) { + VideoButtonState.PLAY -> R.drawable.ic_cell_play + VideoButtonState.PAUSE -> R.drawable.ic_cell_pause + VideoButtonState.REPLAY -> R.drawable.ic_cell_replay + } + Icon( + painter = painterResource(res), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } + } + + // — Bottom controls bar + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(tween(300)) + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(300), + ), + exit = fadeOut(tween(250)) + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(250), + ), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.82f), + ), + ) + ) + .padding(horizontal = dimensions().spacing8x, vertical = dimensions().spacing4x), + ) { + val progress = if (durationMs > 0 && !isSeeking) { + currentPositionMs.toFloat() / durationMs + } else if (isSeeking) { + seekProgress + } else { + 0f + } + + Slider( + value = progress, + onValueChange = { value -> + isSeeking = true + seekProgress = value + }, + onValueChangeFinished = { + videoViewRef?.seekTo((seekProgress * durationMs).toInt()) + currentPositionMs = (seekProgress * durationMs).toInt() + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = Color.White, + activeTrackColor = Color.White, + inactiveTrackColor = Color.White.copy(alpha = 0.35f), + ), + modifier = Modifier.fillMaxWidth(), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing4x), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = currentPositionMs.toTimeString(), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = durationMs.toTimeString(), + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + } + } + } + } + } +} + +private enum class VideoButtonState { PLAY, PAUSE, REPLAY } + +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 PreviewCellVideoViewerScreen() { + WireTheme { + CellVideoViewerScreenContent( + localPath = null, + contentUrl = null, + fileName = "video.mp4", + onNavigateBack = {}, + ) + } +} + diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt new file mode 100644 index 00000000000..58c0f4126da --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt @@ -0,0 +1,37 @@ +/* + * 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.videoviewer + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CellVideoViewerViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val navArgs: CellVideoViewerNavArgs = CellVideoViewerScreenDestination.argsFrom(savedStateHandle) + + val localPath: String? = navArgs.localPath + val contentUrl: String? = navArgs.contentUrl + val fileName: String? = navArgs.fileName +} + diff --git a/features/cells/src/main/res/drawable/ic_cell_pause.xml b/features/cells/src/main/res/drawable/ic_cell_pause.xml new file mode 100644 index 00000000000..bd19f84957b --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_pause.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_cell_play.xml b/features/cells/src/main/res/drawable/ic_cell_play.xml new file mode 100644 index 00000000000..39d8a025608 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_play.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_cell_replay.xml b/features/cells/src/main/res/drawable/ic_cell_replay.xml new file mode 100644 index 00000000000..8dab22ea820 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_replay.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_back.xml b/features/cells/src/main/res/drawable/ic_cell_skip_back.xml new file mode 100644 index 00000000000..ea73bdc16ae --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_skip_back.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml b/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml new file mode 100644 index 00000000000..02e4f2ee56e --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml @@ -0,0 +1,33 @@ + + + + + + + From c295c36a819b5016fc50602692980058297abfb1 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 9 Jun 2026 15:40:28 +0100 Subject: [PATCH 03/18] feat: In-app video loading --- features/cells/build.gradle.kts | 3 + .../ui/videoviewer/CellVideoViewerScreen.kt | 360 ++++++++++++------ .../videoviewer/CellVideoViewerViewModel.kt | 27 ++ .../main/res/drawable/ic_cell_fullscreen.xml | 27 ++ .../res/drawable/ic_cell_fullscreen_exit.xml | 27 ++ .../main/res/drawable/ic_cell_volume_off.xml | 27 ++ .../main/res/drawable/ic_cell_volume_on.xml | 27 ++ .../cells/src/main/res/values/strings.xml | 5 + gradle/libs.versions.toml | 5 + 9 files changed, 397 insertions(+), 111 deletions(-) create mode 100644 features/cells/src/main/res/drawable/ic_cell_fullscreen.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_volume_off.xml create mode 100644 features/cells/src/main/res/drawable/ic_cell_volume_on.xml diff --git a/features/cells/build.gradle.kts b/features/cells/build.gradle.kts index 9c466ee014f..855b176809a 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -38,6 +38,9 @@ dependencies { implementation(libs.coil.video) implementation(libs.coil.compose) + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + implementation(libs.ktx.dateTime) implementation(libs.androidx.paging3) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt index 167db68cd96..d872e279dd9 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt @@ -17,8 +17,12 @@ */ package com.wire.android.feature.cells.ui.videoviewer +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import android.content.res.Configuration import android.net.Uri -import android.widget.VideoView import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -39,6 +43,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -55,9 +60,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf 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 @@ -66,7 +73,9 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -74,6 +83,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.video.VideoFrameDecoder @@ -83,6 +98,7 @@ import com.wire.android.navigation.annotation.features.cells.WireCellsDestinatio import com.wire.android.navigation.style.PopUpNavigationAnimation import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews +import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar @@ -107,8 +123,8 @@ fun CellVideoViewerScreen( viewModel: CellVideoViewerViewModel = hiltViewModel(), ) { CellVideoViewerScreenContent( + player = viewModel.player, localPath = viewModel.localPath, - contentUrl = viewModel.contentUrl, fileName = viewModel.fileName, onNavigateBack = navigator::navigateBack, modifier = modifier, @@ -118,26 +134,37 @@ fun CellVideoViewerScreen( @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun CellVideoViewerScreenContent( + player: ExoPlayer, localPath: String?, - contentUrl: String?, fileName: String?, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current + val view = LocalView.current val scope = rememberCoroutineScope() - // Playback state - var isPlaying by remember { mutableStateOf(false) } - var isStarted by remember { mutableStateOf(false) } - var isCompleted by remember { mutableStateOf(false) } + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + // Flag to track if the screen is immediately exiting + var isExiting by remember { mutableStateOf(false) } + + // Playback state — initialised from the player so it is correct after the activity is recreated + // on rotation (the player itself, held by the ViewModel, keeps its real state). + var isPlaying by remember { mutableStateOf(player.isPlaying) } + var isStarted by remember { mutableStateOf(player.currentPosition > 0L || player.isPlaying) } + var isCompleted by remember { mutableStateOf(player.playbackState == Player.STATE_ENDED) } + var isBuffering by remember { mutableStateOf(player.playbackState == Player.STATE_BUFFERING) } + var isMuted by remember { mutableStateOf(player.volume == 0f) } var controlsVisible by remember { mutableStateOf(true) } - var currentPositionMs by remember { mutableIntStateOf(0) } - var durationMs by remember { mutableIntStateOf(0) } + var currentPositionMs by remember { mutableIntStateOf(player.currentPosition.toInt()) } + var durationMs by remember { mutableIntStateOf(player.duration.coerceAtLeast(0).toInt()) } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(0f) } - var videoViewRef by remember { mutableStateOf(null) } + // Survives the activity recreation triggered by orientation changes + var lockedOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_USER) } + var autoHideJob by remember { mutableStateOf(null) } fun scheduleAutoHide() { @@ -165,22 +192,20 @@ internal fun CellVideoViewerScreenContent( } fun play() { - videoViewRef?.start() - isPlaying = true + player.play() isStarted = true isCompleted = false scheduleAutoHide() } fun pause() { - videoViewRef?.pause() - isPlaying = false + player.pause() autoHideJob?.cancel() showControls(autoHide = false) } fun replay() { - videoViewRef?.seekTo(0) + player.seekTo(0) play() } @@ -194,93 +219,152 @@ internal fun CellVideoViewerScreenContent( } } - // Build video URI - val videoUri = remember(localPath, contentUrl) { - when { - localPath != null -> Uri.fromFile(File(localPath)) - contentUrl != null -> Uri.parse(contentUrl) - else -> null + fun toggleMute() { + isMuted = !isMuted + player.volume = if (isMuted) 0f else 1f + } + + // Reflect player state into Compose state + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + + override fun onPlaybackStateChanged(playbackState: Int) { + isBuffering = playbackState == Player.STATE_BUFFERING + when (playbackState) { + Player.STATE_READY -> durationMs = player.duration.coerceAtLeast(0).toInt() + Player.STATE_ENDED -> { + isCompleted = true + controlsVisible = true + autoHideJob?.cancel() + } + } + } + } + player.addListener(listener) + onDispose { + // The player is owned by the ViewModel; only detach the listener here, do not release it + player.removeListener(listener) } } // Poll playback position while playing LaunchedEffect(isPlaying) { while (isActive && isPlaying) { - val vv = videoViewRef - if (vv != null && !isSeeking) { - currentPositionMs = vv.currentPosition - durationMs = vv.duration.coerceAtLeast(0) + if (!isSeeking) { + currentPositionMs = player.currentPosition.toInt() + durationMs = player.duration.coerceAtLeast(0).toInt() } delay(POSITION_POLL_MS) } } + // Toggle the requested orientation; the layout reacts to the resulting configuration change + fun toggleFullScreen() { + lockedOrientation = if (isLandscape) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + } + + // Apply the requested orientation + LaunchedEffect(lockedOrientation) { + context.findActivity()?.requestedOrientation = lockedOrientation + } + + // Go immersive while in landscape full screen + DisposableEffect(isLandscape) { + val controller = context.findActivity()?.window?.let { WindowInsetsControllerCompat(it, view) } + if (isLandscape) { + controller?.hide(WindowInsetsCompat.Type.systemBars()) + controller?.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + controller?.show(WindowInsetsCompat.Type.systemBars()) + } + onDispose { + controller?.show(WindowInsetsCompat.Type.systemBars()) + } + } + // Cleanup DisposableEffect(Unit) { onDispose { autoHideJob?.cancel() - videoViewRef?.stopPlayback() } } - // Stop playback immediately on back so the video doesn't play during the exit animation + // Set exiting state, tear down the layout, and pause the player so it doesn't keep playing during + // the exit animation. The ViewModel releases the player when it is cleared. fun stopAndNavigateBack() { - videoViewRef?.stopPlayback() - isPlaying = false + isExiting = true + player.pause() + context.findActivity()?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER onNavigateBack() } - BackHandler { stopAndNavigateBack() } + BackHandler { + if (isLandscape) { + toggleFullScreen() + } else { + stopAndNavigateBack() + } + } WireScaffold( modifier = modifier, topBar = { - WireCenterAlignedTopAppBar( - title = fileName ?: stringResource(R.string.conversation_files_title), - navigationIconType = NavigationIconType.Back(), - onNavigationPressed = ::stopAndNavigateBack, - ) + if (!isLandscape && !isExiting) { // Don't show topBar when exiting + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = ::stopAndNavigateBack, + ) + } }, ) { innerPadding -> Box( modifier = Modifier - .padding(innerPadding) + .padding(if (isLandscape) PaddingValues(0.dp) else innerPadding) .fillMaxSize() .background(Color.Black) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, - ) { toggleControls() }, + ) { if (!isExiting) toggleControls() }, contentAlignment = Alignment.Center, ) { - if (videoUri != null) { + // Tear down the AndroidView during the exit animation so playback stops instantly + if (!isExiting) { AndroidView( - factory = { context -> - VideoView(context).apply { - setVideoURI(videoUri) - setOnPreparedListener { mp -> - durationMs = mp.duration - } - setOnCompletionListener { - isPlaying = false - isCompleted = true - controlsVisible = true - autoHideJob?.cancel() - } - }.also { videoViewRef = it } + factory = { ctx -> + PlayerView(ctx).apply { + setPlayer(player) + useController = false + setBackgroundColor(android.graphics.Color.BLACK) + } }, modifier = Modifier.fillMaxSize(), - onRelease = { - videoViewRef?.stopPlayback() - videoViewRef?.suspend() - }, + onRelease = { it.player = null }, + ) + } + + // — Buffering / loading indicator + if (isBuffering && !isExiting) { + WireCircularProgressIndicator( + progressColor = Color.White, + size = dimensions().spacing48x, + modifier = Modifier.size(dimensions().spacing48x), ) } - // — Thumbnail overlay (fade out on first play) + // — Thumbnail overlay AnimatedVisibility( - visible = !isStarted, + visible = !isStarted && !isExiting, exit = fadeOut(animationSpec = tween(durationMillis = 600)), ) { if (localPath != null) { @@ -298,57 +382,59 @@ internal fun CellVideoViewerScreenContent( } } - // — Center play / pause / replay button - val buttonScale by animateFloatAsState( - targetValue = if (controlsVisible) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "videoButtonScale", - ) - - Box( - modifier = Modifier - .size(72.dp) - .scale(buttonScale) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - ) { togglePlayPause() }, - contentAlignment = Alignment.Center, - ) { - AnimatedContent( - targetState = when { - isCompleted -> VideoButtonState.REPLAY - isPlaying -> VideoButtonState.PAUSE - else -> VideoButtonState.PLAY - }, - transitionSpec = { - (scaleIn(animationSpec = spring(Spring.DampingRatioLowBouncy)) + fadeIn()) togetherWith - (scaleOut() + fadeOut()) - }, - label = "videoButtonIcon", - ) { state -> - val res = when (state) { - VideoButtonState.PLAY -> R.drawable.ic_cell_play - VideoButtonState.PAUSE -> R.drawable.ic_cell_pause - VideoButtonState.REPLAY -> R.drawable.ic_cell_replay + // — Center play / pause / replay button (hidden while exiting or buffering) + if (!isExiting && !isBuffering) { + val buttonScale by animateFloatAsState( + targetValue = if (controlsVisible) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "videoButtonScale", + ) + + Box( + modifier = Modifier + .size(72.dp) + .scale(buttonScale) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { togglePlayPause() }, + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = when { + isCompleted -> VideoButtonState.REPLAY + isPlaying -> VideoButtonState.PAUSE + else -> VideoButtonState.PLAY + }, + transitionSpec = { + (scaleIn(animationSpec = spring(Spring.DampingRatioLowBouncy)) + fadeIn()) togetherWith + (scaleOut() + fadeOut()) + }, + label = "videoButtonIcon", + ) { state -> + val res = when (state) { + VideoButtonState.PLAY -> R.drawable.ic_cell_play + VideoButtonState.PAUSE -> R.drawable.ic_cell_pause + VideoButtonState.REPLAY -> R.drawable.ic_cell_replay + } + Icon( + painter = painterResource(res), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp), + ) } - Icon( - painter = painterResource(res), - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(40.dp), - ) } } - // — Bottom controls bar + // — Bottom controls bar (Only show if not exiting) AnimatedVisibility( - visible = controlsVisible, + visible = controlsVisible && !isExiting, enter = fadeIn(tween(300)) + slideInVertically( initialOffsetY = { it }, animationSpec = tween(300), @@ -387,8 +473,9 @@ internal fun CellVideoViewerScreenContent( seekProgress = value }, onValueChangeFinished = { - videoViewRef?.seekTo((seekProgress * durationMs).toInt()) - currentPositionMs = (seekProgress * durationMs).toInt() + val target = (seekProgress * durationMs).toLong() + player.seekTo(target) + currentPositionMs = target.toInt() isSeeking = false }, colors = SliderDefaults.colors( @@ -404,6 +491,7 @@ internal fun CellVideoViewerScreenContent( .fillMaxWidth() .padding(horizontal = dimensions().spacing4x), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { Text( text = currentPositionMs.toTimeString(), @@ -411,11 +499,53 @@ internal fun CellVideoViewerScreenContent( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Text( - text = durationMs.toTimeString(), - color = Color.White.copy(alpha = 0.7f), - fontSize = 12.sp, - ) + + Row( + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing12x), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = durationMs.toTimeString(), + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + Icon( + painter = painterResource( + if (isMuted) R.drawable.ic_cell_volume_off else R.drawable.ic_cell_volume_on + ), + contentDescription = stringResource( + if (isMuted) R.string.cells_video_unmute else R.string.cells_video_mute + ), + tint = Color.White, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { toggleMute() }, + ) + Icon( + painter = painterResource( + if (isLandscape) R.drawable.ic_cell_fullscreen_exit else R.drawable.ic_cell_fullscreen + ), + contentDescription = stringResource( + if (isLandscape) { + R.string.cells_video_exit_fullscreen + } else { + R.string.cells_video_enter_fullscreen + } + ), + tint = Color.White, + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { toggleFullScreen() }, + ) + } } } } @@ -425,6 +555,12 @@ internal fun CellVideoViewerScreenContent( private enum class VideoButtonState { PLAY, PAUSE, REPLAY } +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + private fun Int.toTimeString(): String { val totalSec = this / 1000 val min = totalSec / 60 @@ -435,10 +571,12 @@ private fun Int.toTimeString(): String { @MultipleThemePreviews @Composable fun PreviewCellVideoViewerScreen() { + val context = LocalContext.current + val player = remember { ExoPlayer.Builder(context).build() } WireTheme { CellVideoViewerScreenContent( + player = player, localPath = null, - contentUrl = null, fileName = "video.mp4", onNavigateBack = {}, ) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt index 58c0f4126da..f9bb31a23ea 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt @@ -17,14 +17,21 @@ */ package com.wire.android.feature.cells.ui.videoviewer +import android.content.Context +import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File import javax.inject.Inject @HiltViewModel class CellVideoViewerViewModel @Inject constructor( + @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -33,5 +40,25 @@ class CellVideoViewerViewModel @Inject constructor( val localPath: String? = navArgs.localPath val contentUrl: String? = navArgs.contentUrl val fileName: String? = navArgs.fileName + + // Held in the ViewModel so playback survives configuration changes (e.g. rotating to full screen) + // without re-buffering the media. + val player: ExoPlayer = ExoPlayer.Builder(context).build().apply { + videoUri()?.let { + setMediaItem(MediaItem.fromUri(it)) + prepare() + } + } + + private fun videoUri(): Uri? = when { + localPath != null -> Uri.fromFile(File(localPath)) + contentUrl != null -> Uri.parse(contentUrl) + else -> null + } + + override fun onCleared() { + super.onCleared() + player.release() + } } diff --git a/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml b/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml new file mode 100644 index 00000000000..a9346240281 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml b/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml new file mode 100644 index 00000000000..652375d0ddb --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_off.xml b/features/cells/src/main/res/drawable/ic_cell_volume_off.xml new file mode 100644 index 00000000000..ab7e71e81c4 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_volume_off.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_on.xml b/features/cells/src/main/res/drawable/ic_cell_volume_on.xml new file mode 100644 index 00000000000..0c6eb40e34c --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_cell_volume_on.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/values/strings.xml b/features/cells/src/main/res/values/strings.xml index adb7b1e4d7c..93a51ee8010 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -251,4 +251,9 @@ Smallest first Largest first Image + Enter full screen + Exit full screen + Mute + Unmute + Loading video diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ea998d7475..fce39a1dfef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,6 +72,7 @@ accompanist = "0.32.0" material = "1.12.0" material3 = "1.4.0" coil = "3.4.0" +media3 = "1.10.1" commonmark = "0.28.0" # Countly @@ -263,6 +264,10 @@ coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +# Media playback +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } + # RSS Feed Loading rss-parser = { module = "com.prof18.rssparser:rssparser", version.ref = "rss-parser" } From 8e2d3418589baa4e22fb763229c66c41d4f44097 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 08:35:41 +0100 Subject: [PATCH 04/18] feat: conflicts --- .../android/feature/cells/ui/CellViewModel.kt | 1 - .../cells/ui/CellsMetroViewModelBindings.kt | 29 +++++++++++++++++++ .../feature/cells/ui/CellsViewModelFactory.kt | 15 ++++++++++ .../feature/cells/ui/CellsViewModelGraph.kt | 8 +++++ .../cells/ui/ConversationFilesScreen.kt | 7 ++--- .../ui/audioplayer/CellAudioPlayerScreen.kt | 4 +-- .../audioplayer/CellAudioPlayerViewModel.kt | 5 +--- .../ui/videoviewer/CellVideoViewerScreen.kt | 12 +++----- .../videoviewer/CellVideoViewerViewModel.kt | 9 ++---- 9 files changed, 64 insertions(+), 26 deletions(-) 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 a3fa74ceb5b..1a31bb3a13d 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 @@ -33,7 +33,6 @@ import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.NodeBottomSheetAction import com.wire.android.feature.cells.ui.model.OpenLoadState -import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.model.canOpenWithUrl import com.wire.android.feature.cells.ui.model.localFileAvailable import com.wire.android.feature.cells.ui.model.toUiModel 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 65d887db65b..0c194ee81a7 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 @@ -18,10 +18,13 @@ package com.wire.android.feature.cells.ui 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.CellAudioPlayerViewModel 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 import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel import com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel @@ -30,6 +33,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides @@ -105,6 +109,31 @@ object CellsMetroViewModelBindings { fun versionHistoryViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = savedStateViewModel { factory.versionHistoryViewModel(it.createSavedStateHandle()) } + @Provides + @IntoMap + @ViewModelAssistedFactoryKey(CellImageViewerViewModel::class) + fun imageViewerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = + savedStateViewModel { factory.cellImageViewerViewModel(it.createSavedStateHandle()) } + + @Provides + @IntoMap + @ViewModelAssistedFactoryKey(CellVideoViewerViewModel::class) + fun videoViewerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = + savedStateViewModel { + factory.cellVideoViewerViewModel( + context = checkNotNull(it[APPLICATION_KEY]) { + "No Application was provided via CreationExtras" + }, + savedStateHandle = it.createSavedStateHandle(), + ) + } + + @Provides + @IntoMap + @ViewModelAssistedFactoryKey(CellAudioPlayerViewModel::class) + fun audioPlayerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = + savedStateViewModel { factory.cellAudioPlayerViewModel(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 dcc141d31d9..fff1c2fc108 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 @@ -17,7 +17,9 @@ */ package com.wire.android.feature.cells.ui +import android.content.Context import androidx.lifecycle.SavedStateHandle +import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerViewModel 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 @@ -30,6 +32,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter import com.wire.android.util.dispatchers.DispatcherProvider @@ -214,4 +217,16 @@ class CellsViewModelFactory @Inject constructor( internal fun cellImageViewerViewModel(savedStateHandle: SavedStateHandle) = CellImageViewerViewModel( savedStateHandle = savedStateHandle, ) + + internal fun cellVideoViewerViewModel( + context: Context, + savedStateHandle: SavedStateHandle + ) = CellVideoViewerViewModel( + context = context, + savedStateHandle = savedStateHandle, + ) + + internal fun cellAudioPlayerViewModel(savedStateHandle: SavedStateHandle) = CellAudioPlayerViewModel( + 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 5c49860903a..4a777137166 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 @@ -22,6 +22,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.CellAudioPlayerViewModel 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 @@ -33,6 +34,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel @Composable inline fun cellsViewModel( @@ -87,3 +89,9 @@ fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel() @Composable fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() + +@Composable +fun cellVideoViewerViewModel(): CellVideoViewerViewModel = cellsViewModel() + +@Composable +fun cellAudioPlayerViewModel(): CellAudioPlayerViewModel = 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 c76e4c9bbb6..47d72f25646 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,7 +45,6 @@ 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.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination @@ -60,15 +59,15 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType -import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs -import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs +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 import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet +import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt index 6226c9cbdeb..70b7b9dca9f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt @@ -71,8 +71,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel 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 @@ -101,7 +101,7 @@ private val AccentLight = Color(0xFFA29BFE) fun CellAudioPlayerScreen( navigator: WireNavigator, modifier: Modifier = Modifier, - viewModel: CellAudioPlayerViewModel = hiltViewModel(), + viewModel: CellAudioPlayerViewModel = cellAudioPlayerViewModel(), ) { CellAudioPlayerContent( localPath = viewModel.localPath, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt index 18bce3ea462..fdb10e58a63 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt @@ -20,11 +20,8 @@ package com.wire.android.feature.cells.ui.audioplayer import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -@HiltViewModel -class CellAudioPlayerViewModel @Inject constructor( +class CellAudioPlayerViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { private val navArgs = CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt index d872e279dd9..b4c5e112b5e 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt @@ -22,7 +22,6 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.content.res.Configuration -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -60,7 +59,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -82,17 +80,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.media3.common.MediaItem +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.video.VideoFrameDecoder import com.wire.android.feature.cells.R +import com.wire.android.feature.cells.ui.cellVideoViewerViewModel import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.cells.WireCellsDestination import com.wire.android.navigation.style.PopUpNavigationAnimation @@ -107,7 +104,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.io.File private const val CONTROLS_AUTO_HIDE_MS = 3_000L private const val POSITION_POLL_MS = 200L @@ -120,7 +116,7 @@ private const val POSITION_POLL_MS = 200L fun CellVideoViewerScreen( navigator: WireNavigator, modifier: Modifier = Modifier, - viewModel: CellVideoViewerViewModel = hiltViewModel(), + viewModel: CellVideoViewerViewModel = cellVideoViewerViewModel(), ) { CellVideoViewerScreenContent( player = viewModel.player, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt index f9bb31a23ea..1398063114b 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt @@ -24,14 +24,10 @@ import androidx.lifecycle.ViewModel import androidx.media3.common.MediaItem import androidx.media3.exoplayer.ExoPlayer import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File -import javax.inject.Inject -@HiltViewModel -class CellVideoViewerViewModel @Inject constructor( - @ApplicationContext context: Context, +class CellVideoViewerViewModel( + context: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -61,4 +57,3 @@ class CellVideoViewerViewModel @Inject constructor( player.release() } } - From 068cd3bfcc867b1bd844b4c0bead52baea001def Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 10:03:34 +0100 Subject: [PATCH 05/18] feat: cleanup --- .../feature/cells/ui/AllFilesScreen.kt | 6 +- .../cells/ui/CellsMetroViewModelBindings.kt | 4 +- .../feature/cells/ui/CellsViewModelFactory.kt | 4 +- .../feature/cells/ui/CellsViewModelGraph.kt | 4 +- .../cells/ui/ConversationFilesScreen.kt | 6 +- .../feature/cells/ui/search/SearchScreen.kt | 6 +- .../CellVideoViewerNavArgs.kt | 2 +- .../ui/videoplayer/VideoPlaybackState.kt | 28 +++ .../VideoPlayerScreen.kt} | 165 ++++++----------- .../ui/videoplayer/VideoPlayerViewModel.kt | 168 ++++++++++++++++++ .../videoviewer/CellVideoViewerViewModel.kt | 59 ------ 11 files changed, 263 insertions(+), 189 deletions(-) rename features/cells/src/main/java/com/wire/android/feature/cells/ui/{videoviewer => videoplayer}/CellVideoViewerNavArgs.kt (93%) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt rename features/cells/src/main/java/com/wire/android/feature/cells/ui/{videoviewer/CellVideoViewerScreen.kt => videoplayer/VideoPlayerScreen.kt} (78%) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt 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 7d1e18d1d33..f9a543e837f 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 @@ -30,12 +30,12 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination -import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination 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.common.OfflineBanner -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType @@ -140,7 +140,7 @@ fun AllFilesScreen( showVideoViewer = { file -> navigator.navigate( NavigationCommand( - CellVideoViewerScreenDestination( + VideoPlayerScreenDestination( CellVideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, 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 0c194ee81a7..6c6f1650723 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 @@ -33,7 +33,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel +import com.wire.android.feature.cells.ui.videoplayer.VideoPlayerViewModel import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides @@ -117,7 +117,7 @@ object CellsMetroViewModelBindings { @Provides @IntoMap - @ViewModelAssistedFactoryKey(CellVideoViewerViewModel::class) + @ViewModelAssistedFactoryKey(VideoPlayerViewModel::class) fun videoViewerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = savedStateViewModel { factory.cellVideoViewerViewModel( 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 fff1c2fc108..5e71aca1eb5 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 @@ -32,7 +32,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel +import com.wire.android.feature.cells.ui.videoplayer.VideoPlayerViewModel import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter import com.wire.android.util.dispatchers.DispatcherProvider @@ -221,7 +221,7 @@ class CellsViewModelFactory @Inject constructor( internal fun cellVideoViewerViewModel( context: Context, savedStateHandle: SavedStateHandle - ) = CellVideoViewerViewModel( + ) = VideoPlayerViewModel( 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 4a777137166..703ac751a56 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 @@ -34,7 +34,7 @@ import com.wire.android.feature.cells.ui.rename.RenameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryViewModel -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerViewModel +import com.wire.android.feature.cells.ui.videoplayer.VideoPlayerViewModel @Composable inline fun cellsViewModel( @@ -91,7 +91,7 @@ fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel() fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() @Composable -fun cellVideoViewerViewModel(): CellVideoViewerViewModel = cellsViewModel() +fun cellVideoViewerViewModel(): VideoPlayerViewModel = cellsViewModel() @Composable fun cellAudioPlayerViewModel(): CellAudioPlayerViewModel = 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 47d72f25646..17e4ae5a9d9 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 @@ -47,7 +47,6 @@ 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.CellVideoViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination @@ -57,6 +56,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.RecycleBinS import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination +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.CellAudioPlayerNavArgs @@ -67,7 +67,7 @@ import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator @@ -385,7 +385,7 @@ internal fun ConversationFilesScreenContent( showVideoViewer = { file -> navigator.navigate( NavigationCommand( - CellVideoViewerScreenDestination( + VideoPlayerScreenDestination( CellVideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, 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 77ad234a961..c42b048e769 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 @@ -43,18 +43,18 @@ 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.CellVideoViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.RenameNodeScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.VersionHistoryScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination 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.common.OfflineBanner import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs -import com.wire.android.feature.cells.ui.videoviewer.CellVideoViewerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet @@ -275,7 +275,7 @@ fun SearchScreen( showVideoViewer = { file -> navigator.navigate( NavigationCommand( - CellVideoViewerScreenDestination( + VideoPlayerScreenDestination( CellVideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt similarity index 93% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt index e3e01124ffd..138b86376f4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt @@ -15,7 +15,7 @@ * 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.videoviewer +package com.wire.android.feature.cells.ui.videoplayer data class CellVideoViewerNavArgs( val localPath: String? = null, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt new file mode 100644 index 00000000000..3f8525614ae --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt @@ -0,0 +1,28 @@ +/* + * 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.videoplayer + +data class VideoPlaybackState( + val isPlaying: Boolean = false, + val isStarted: Boolean = false, + val isCompleted: Boolean = false, + val isBuffering: Boolean = false, + val isMuted: Boolean = false, + val currentPositionMs: Int = 0, + val durationMs: Int = 0, +) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt similarity index 78% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt index b4c5e112b5e..01521d18949 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt @@ -15,7 +15,7 @@ * 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.videoviewer +package com.wire.android.feature.cells.ui.videoplayer import android.app.Activity import android.content.Context @@ -82,7 +82,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat -import androidx.media3.common.Player +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage @@ -102,26 +102,29 @@ import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.theme.WireTheme import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch private const val CONTROLS_AUTO_HIDE_MS = 3_000L -private const val POSITION_POLL_MS = 200L @WireCellsDestination( style = PopUpNavigationAnimation::class, navArgs = CellVideoViewerNavArgs::class, ) @Composable -fun CellVideoViewerScreen( +fun VideoPlayerScreen( navigator: WireNavigator, modifier: Modifier = Modifier, - viewModel: CellVideoViewerViewModel = cellVideoViewerViewModel(), + viewModel: VideoPlayerViewModel = cellVideoViewerViewModel(), ) { + val state by viewModel.state.collectAsStateWithLifecycle() CellVideoViewerScreenContent( player = viewModel.player, + state = state, localPath = viewModel.localPath, fileName = viewModel.fileName, + onTogglePlayPause = viewModel::togglePlayPause, + onToggleMute = viewModel::toggleMute, + onSeek = viewModel::seekTo, onNavigateBack = navigator::navigateBack, modifier = modifier, ) @@ -131,8 +134,12 @@ fun CellVideoViewerScreen( @Composable internal fun CellVideoViewerScreenContent( player: ExoPlayer, + state: VideoPlaybackState, localPath: String?, fileName: String?, + onTogglePlayPause: () -> Unit, + onToggleMute: () -> Unit, + onSeek: (Long) -> Unit, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -145,16 +152,9 @@ internal fun CellVideoViewerScreenContent( // Flag to track if the screen is immediately exiting var isExiting by remember { mutableStateOf(false) } - // Playback state — initialised from the player so it is correct after the activity is recreated - // on rotation (the player itself, held by the ViewModel, keeps its real state). - var isPlaying by remember { mutableStateOf(player.isPlaying) } - var isStarted by remember { mutableStateOf(player.currentPosition > 0L || player.isPlaying) } - var isCompleted by remember { mutableStateOf(player.playbackState == Player.STATE_ENDED) } - var isBuffering by remember { mutableStateOf(player.playbackState == Player.STATE_BUFFERING) } - var isMuted by remember { mutableStateOf(player.volume == 0f) } + // UI-only state. Playback state (playing, position, duration, mute, …) lives in the ViewModel so + // it survives the activity recreation on rotation; the values below are pure presentation concerns. var controlsVisible by remember { mutableStateOf(true) } - var currentPositionMs by remember { mutableIntStateOf(player.currentPosition.toInt()) } - var durationMs by remember { mutableIntStateOf(player.duration.coerceAtLeast(0).toInt()) } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(0f) } @@ -171,14 +171,14 @@ internal fun CellVideoViewerScreenContent( } } - fun showControls(autoHide: Boolean = isPlaying) { + fun showControls(autoHide: Boolean = state.isPlaying) { controlsVisible = true if (autoHide) scheduleAutoHide() } fun toggleControls() { if (controlsVisible) { - if (isPlaying) { + if (state.isPlaying) { autoHideJob?.cancel() controlsVisible = false } @@ -187,73 +187,19 @@ internal fun CellVideoViewerScreenContent( } } - fun play() { - player.play() - isStarted = true - isCompleted = false - scheduleAutoHide() - } - - fun pause() { - player.pause() - autoHideJob?.cancel() - showControls(autoHide = false) - } - - fun replay() { - player.seekTo(0) - play() - } - - fun togglePlayPause() { - if (isCompleted) { - replay() - } else if (isPlaying) { - pause() - } else { - play() - } - } - - fun toggleMute() { - isMuted = !isMuted - player.volume = if (isMuted) 0f else 1f - } - - // Reflect player state into Compose state - DisposableEffect(player) { - val listener = object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - isPlaying = playing + // auto-hide while playing, keep visible while paused or completed. + LaunchedEffect(state.isPlaying, state.isCompleted) { + when { + state.isCompleted -> { + autoHideJob?.cancel() + controlsVisible = true } - override fun onPlaybackStateChanged(playbackState: Int) { - isBuffering = playbackState == Player.STATE_BUFFERING - when (playbackState) { - Player.STATE_READY -> durationMs = player.duration.coerceAtLeast(0).toInt() - Player.STATE_ENDED -> { - isCompleted = true - controlsVisible = true - autoHideJob?.cancel() - } - } - } - } - player.addListener(listener) - onDispose { - // The player is owned by the ViewModel; only detach the listener here, do not release it - player.removeListener(listener) - } - } - - // Poll playback position while playing - LaunchedEffect(isPlaying) { - while (isActive && isPlaying) { - if (!isSeeking) { - currentPositionMs = player.currentPosition.toInt() - durationMs = player.duration.coerceAtLeast(0).toInt() + state.isPlaying -> scheduleAutoHide() + else -> { + autoHideJob?.cancel() + showControls(autoHide = false) } - delay(POSITION_POLL_MS) } } @@ -266,12 +212,10 @@ internal fun CellVideoViewerScreenContent( } } - // Apply the requested orientation LaunchedEffect(lockedOrientation) { context.findActivity()?.requestedOrientation = lockedOrientation } - // Go immersive while in landscape full screen DisposableEffect(isLandscape) { val controller = context.findActivity()?.window?.let { WindowInsetsControllerCompat(it, view) } if (isLandscape) { @@ -286,16 +230,13 @@ internal fun CellVideoViewerScreenContent( } } - // Cleanup DisposableEffect(Unit) { onDispose { autoHideJob?.cancel() } } - // Set exiting state, tear down the layout, and pause the player so it doesn't keep playing during - // the exit animation. The ViewModel releases the player when it is cleared. - fun stopAndNavigateBack() { + fun stopAndNavigateBack() { isExiting = true player.pause() context.findActivity()?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER @@ -313,7 +254,7 @@ internal fun CellVideoViewerScreenContent( WireScaffold( modifier = modifier, topBar = { - if (!isLandscape && !isExiting) { // Don't show topBar when exiting + if (!isLandscape && !isExiting) { WireCenterAlignedTopAppBar( title = fileName ?: stringResource(R.string.conversation_files_title), navigationIconType = NavigationIconType.Back(), @@ -334,7 +275,6 @@ internal fun CellVideoViewerScreenContent( ) { if (!isExiting) toggleControls() }, contentAlignment = Alignment.Center, ) { - // Tear down the AndroidView during the exit animation so playback stops instantly if (!isExiting) { AndroidView( factory = { ctx -> @@ -349,8 +289,7 @@ internal fun CellVideoViewerScreenContent( ) } - // — Buffering / loading indicator - if (isBuffering && !isExiting) { + if (state.isBuffering && !isExiting) { WireCircularProgressIndicator( progressColor = Color.White, size = dimensions().spacing48x, @@ -358,9 +297,8 @@ internal fun CellVideoViewerScreenContent( ) } - // — Thumbnail overlay AnimatedVisibility( - visible = !isStarted && !isExiting, + visible = !state.isStarted && !isExiting, exit = fadeOut(animationSpec = tween(durationMillis = 600)), ) { if (localPath != null) { @@ -378,8 +316,7 @@ internal fun CellVideoViewerScreenContent( } } - // — Center play / pause / replay button (hidden while exiting or buffering) - if (!isExiting && !isBuffering) { + if (!isExiting && !state.isBuffering) { val buttonScale by animateFloatAsState( targetValue = if (controlsVisible) 1f else 0f, animationSpec = spring( @@ -391,20 +328,20 @@ internal fun CellVideoViewerScreenContent( Box( modifier = Modifier - .size(72.dp) + .size(dimensions().spacing72x) .scale(buttonScale) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.45f)) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, - ) { togglePlayPause() }, + ) { onTogglePlayPause() }, contentAlignment = Alignment.Center, ) { AnimatedContent( targetState = when { - isCompleted -> VideoButtonState.REPLAY - isPlaying -> VideoButtonState.PAUSE + state.isCompleted -> VideoButtonState.REPLAY + state.isPlaying -> VideoButtonState.PAUSE else -> VideoButtonState.PLAY }, transitionSpec = { @@ -412,8 +349,8 @@ internal fun CellVideoViewerScreenContent( (scaleOut() + fadeOut()) }, label = "videoButtonIcon", - ) { state -> - val res = when (state) { + ) { buttonState -> + val res = when (buttonState) { VideoButtonState.PLAY -> R.drawable.ic_cell_play VideoButtonState.PAUSE -> R.drawable.ic_cell_pause VideoButtonState.REPLAY -> R.drawable.ic_cell_replay @@ -428,7 +365,6 @@ internal fun CellVideoViewerScreenContent( } } - // — Bottom controls bar (Only show if not exiting) AnimatedVisibility( visible = controlsVisible && !isExiting, enter = fadeIn(tween(300)) + slideInVertically( @@ -454,8 +390,8 @@ internal fun CellVideoViewerScreenContent( ) .padding(horizontal = dimensions().spacing8x, vertical = dimensions().spacing4x), ) { - val progress = if (durationMs > 0 && !isSeeking) { - currentPositionMs.toFloat() / durationMs + val progress = if (state.durationMs > 0 && !isSeeking) { + state.currentPositionMs.toFloat() / state.durationMs } else if (isSeeking) { seekProgress } else { @@ -469,9 +405,7 @@ internal fun CellVideoViewerScreenContent( seekProgress = value }, onValueChangeFinished = { - val target = (seekProgress * durationMs).toLong() - player.seekTo(target) - currentPositionMs = target.toInt() + onSeek((seekProgress * state.durationMs).toLong()) isSeeking = false }, colors = SliderDefaults.colors( @@ -490,7 +424,7 @@ internal fun CellVideoViewerScreenContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = currentPositionMs.toTimeString(), + text = state.currentPositionMs.toTimeString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -501,25 +435,25 @@ internal fun CellVideoViewerScreenContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = durationMs.toTimeString(), + text = state.durationMs.toTimeString(), color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp, ) Icon( painter = painterResource( - if (isMuted) R.drawable.ic_cell_volume_off else R.drawable.ic_cell_volume_on + if (state.isMuted) R.drawable.ic_cell_volume_off else R.drawable.ic_cell_volume_on ), contentDescription = stringResource( - if (isMuted) R.string.cells_video_unmute else R.string.cells_video_mute + if (state.isMuted) R.string.cells_video_unmute else R.string.cells_video_mute ), tint = Color.White, modifier = Modifier - .size(24.dp) + .size(dimensions().spacing24x) .clip(CircleShape) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, - ) { toggleMute() }, + ) { onToggleMute() }, ) Icon( painter = painterResource( @@ -572,10 +506,13 @@ fun PreviewCellVideoViewerScreen() { WireTheme { CellVideoViewerScreenContent( player = player, + state = VideoPlaybackState(), localPath = null, fileName = "video.mp4", + onTogglePlayPause = {}, + onToggleMute = {}, + onSeek = {}, onNavigateBack = {}, ) } } - diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt new file mode 100644 index 00000000000..3eba6bad87e --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt @@ -0,0 +1,168 @@ +/* + * 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.videoplayer + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination +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 +import java.io.File + +class VideoPlayerViewModel( + @ApplicationContext context: Context, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val navArgs: CellVideoViewerNavArgs = VideoPlayerScreenDestination.argsFrom(savedStateHandle) + + val localPath: String? = navArgs.localPath + val contentUrl: String? = navArgs.contentUrl + val fileName: String? = navArgs.fileName + + // Held in the ViewModel so playback survives configuration changes (e.g. rotating to full screen) + // without re-buffering the media. The PlayerView in the UI binds to this instance directly. + val player: ExoPlayer = ExoPlayer.Builder(context).build() + + private val _state = MutableStateFlow( + VideoPlaybackState( + isPlaying = player.isPlaying, + isStarted = player.currentPosition > 0L || player.isPlaying, + isCompleted = player.playbackState == Player.STATE_ENDED, + isBuffering = player.playbackState == Player.STATE_BUFFERING, + isMuted = player.volume == 0f, + currentPositionMs = player.currentPosition.toInt(), + durationMs = player.duration.coerceAtLeast(0).toInt(), + ) + ) + val state: StateFlow = _state.asStateFlow() + + private var positionPollJob: Job? = null + + private val playerListener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + _state.update { it.copy(isPlaying = playing) } + if (playing) startPositionPolling() else stopPositionPolling() + } + + override fun onPlaybackStateChanged(playbackState: Int) { + _state.update { it.copy(isBuffering = playbackState == Player.STATE_BUFFERING) } + when (playbackState) { + Player.STATE_READY -> _state.update { + it.copy(durationMs = player.duration.coerceAtLeast(0).toInt()) + } + + Player.STATE_ENDED -> _state.update { it.copy(isCompleted = true) } + } + } + } + + init { + player.addListener(playerListener) + videoUri()?.let { + player.setMediaItem(MediaItem.fromUri(it)) + player.prepare() + } + if (player.isPlaying) startPositionPolling() + } + + fun play() { + player.play() + _state.update { it.copy(isStarted = true, isCompleted = false) } + } + + fun pause() { + player.pause() + } + + fun replay() { + player.seekTo(0) + play() + } + + fun togglePlayPause() { + val current = _state.value + when { + current.isCompleted -> replay() + current.isPlaying -> pause() + else -> play() + } + } + + fun toggleMute() { + val muted = !_state.value.isMuted + player.volume = if (muted) 0f else 1f + _state.update { it.copy(isMuted = muted) } + } + + fun seekTo(positionMs: Long) { + player.seekTo(positionMs) + _state.update { it.copy(currentPositionMs = positionMs.toInt()) } + } + + private fun startPositionPolling() { + if (positionPollJob?.isActive == true) return + positionPollJob = viewModelScope.launch { + while (isActive) { + _state.update { + it.copy( + currentPositionMs = player.currentPosition.toInt(), + durationMs = player.duration.coerceAtLeast(0).toInt(), + ) + } + delay(POSITION_POLL_MS) + } + } + } + + private fun stopPositionPolling() { + positionPollJob?.cancel() + positionPollJob = null + } + + private fun videoUri(): Uri? = when { + localPath != null -> Uri.fromFile(File(localPath)) + contentUrl != null -> Uri.parse(contentUrl) + else -> null + } + + override fun onCleared() { + super.onCleared() + stopPositionPolling() + player.removeListener(playerListener) + player.release() + } + + private companion object { + const val POSITION_POLL_MS = 200L + } +} diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt deleted file mode 100644 index 1398063114b..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoviewer/CellVideoViewerViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.videoviewer - -import android.content.Context -import android.net.Uri -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.ExoPlayer -import com.ramcosta.composedestinations.generated.cells.destinations.CellVideoViewerScreenDestination -import java.io.File - -class CellVideoViewerViewModel( - context: Context, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - - private val navArgs: CellVideoViewerNavArgs = CellVideoViewerScreenDestination.argsFrom(savedStateHandle) - - val localPath: String? = navArgs.localPath - val contentUrl: String? = navArgs.contentUrl - val fileName: String? = navArgs.fileName - - // Held in the ViewModel so playback survives configuration changes (e.g. rotating to full screen) - // without re-buffering the media. - val player: ExoPlayer = ExoPlayer.Builder(context).build().apply { - videoUri()?.let { - setMediaItem(MediaItem.fromUri(it)) - prepare() - } - } - - private fun videoUri(): Uri? = when { - localPath != null -> Uri.fromFile(File(localPath)) - contentUrl != null -> Uri.parse(contentUrl) - else -> null - } - - override fun onCleared() { - super.onCleared() - player.release() - } -} From 28f34ef7c45185be0066c885f2b75e0eb997292b Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 10:16:51 +0100 Subject: [PATCH 06/18] feat: cleanup --- .../feature/cells/ui/AllFilesScreen.kt | 8 +- .../cells/ui/CellsMetroViewModelBindings.kt | 9 +- .../feature/cells/ui/CellsViewModelFactory.kt | 6 +- .../cells/ui/ConversationFilesScreen.kt | 8 +- ...PlayerNavArgs.kt => AudioPlayerNavArgs.kt} | 2 +- ...ioPlayerScreen.kt => AudioPlayerScreen.kt} | 148 ++++-------------- .../ui/audioplayer/AudioPlayerViewModel.kt | 145 +++++++++++++++++ .../audioplayer/CellAudioPlayerViewModel.kt | 32 ---- .../feature/cells/ui/search/SearchScreen.kt | 8 +- .../cells/ui/videoplayer/VideoPlayerScreen.kt | 2 +- .../ui/videoplayer/VideoPlayerViewModel.kt | 3 +- ...ViewerNavArgs.kt => VideoViewerNavArgs.kt} | 2 +- 12 files changed, 205 insertions(+), 168 deletions(-) rename features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/{CellAudioPlayerNavArgs.kt => AudioPlayerNavArgs.kt} (96%) rename features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/{CellAudioPlayerScreen.kt => AudioPlayerScreen.kt} (78%) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt rename features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/{CellVideoViewerNavArgs.kt => VideoViewerNavArgs.kt} (96%) 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 f9a543e837f..da3dcaa757b 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 @@ -35,8 +35,8 @@ import com.ramcosta.composedestinations.generated.cells.destinations.SearchScree import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.OfflineBanner -import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs -import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType import com.wire.android.navigation.NavigationCommand @@ -141,7 +141,7 @@ fun AllFilesScreen( navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( - CellVideoViewerNavArgs( + VideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, @@ -154,7 +154,7 @@ fun AllFilesScreen( navigator.navigate( NavigationCommand( CellAudioPlayerScreenDestination( - CellAudioPlayerNavArgs( + AudioPlayerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, 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 6c6f1650723..04562346541 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 @@ -132,7 +132,14 @@ object CellsMetroViewModelBindings { @IntoMap @ViewModelAssistedFactoryKey(CellAudioPlayerViewModel::class) fun audioPlayerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = - savedStateViewModel { factory.cellAudioPlayerViewModel(it.createSavedStateHandle()) } + 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 { 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 5e71aca1eb5..201a0a96368 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 @@ -226,7 +226,11 @@ class CellsViewModelFactory @Inject constructor( savedStateHandle = savedStateHandle, ) - internal fun cellAudioPlayerViewModel(savedStateHandle: SavedStateHandle) = CellAudioPlayerViewModel( + internal fun cellAudioPlayerViewModel( + context: Context, + savedStateHandle: SavedStateHandle + ) = CellAudioPlayerViewModel( + context = context, savedStateHandle = savedStateHandle, ) } 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 17e4ae5a9d9..2257afb2a74 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 @@ -59,7 +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.CellAudioPlayerNavArgs +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 @@ -67,7 +67,7 @@ import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet import com.wire.android.feature.cells.ui.dialog.CellsOptionsBottomSheet import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi -import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator @@ -386,7 +386,7 @@ internal fun ConversationFilesScreenContent( navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( - CellVideoViewerNavArgs( + VideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, @@ -399,7 +399,7 @@ internal fun ConversationFilesScreenContent( navigator.navigate( NavigationCommand( CellAudioPlayerScreenDestination( - CellAudioPlayerNavArgs( + AudioPlayerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt similarity index 96% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt index bc35d61bac4..c8a9173ee4f 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt @@ -17,7 +17,7 @@ */ package com.wire.android.feature.cells.ui.audioplayer -data class CellAudioPlayerNavArgs( +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/CellAudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt similarity index 78% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt index 70b7b9dca9f..15067dc93f0 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt @@ -17,8 +17,6 @@ */ package com.wire.android.feature.cells.ui.audioplayer -import android.media.MediaPlayer -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing @@ -49,11 +47,8 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -63,7 +58,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -71,6 +65,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +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 @@ -82,10 +77,7 @@ 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.theme.WireTheme -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -private const val POSITION_POLL_MS = 200L private const val SKIP_MS = 15_000 private val BackgroundTop = Color(0xFF1A1A2E) @@ -95,7 +87,7 @@ private val AccentLight = Color(0xFFA29BFE) @WireCellsDestination( style = PopUpNavigationAnimation::class, - navArgs = CellAudioPlayerNavArgs::class, + navArgs = AudioPlayerNavArgs::class, ) @Composable fun CellAudioPlayerScreen( @@ -103,10 +95,13 @@ fun CellAudioPlayerScreen( modifier: Modifier = Modifier, viewModel: CellAudioPlayerViewModel = cellAudioPlayerViewModel(), ) { + val state by viewModel.state.collectAsStateWithLifecycle() CellAudioPlayerContent( - localPath = viewModel.localPath, - contentUrl = viewModel.contentUrl, + state = state, fileName = viewModel.fileName, + onTogglePlayPause = viewModel::togglePlayPause, + onSeek = viewModel::seekTo, + onStop = viewModel::pause, onNavigateBack = navigator::navigateBack, modifier = modifier, ) @@ -115,103 +110,24 @@ fun CellAudioPlayerScreen( @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun CellAudioPlayerContent( - localPath: String?, - contentUrl: String?, + state: AudioPlaybackState, fileName: String?, + onTogglePlayPause: () -> Unit, + onSeek: (Int) -> Unit, + onStop: () -> Unit, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - - var isPlaying by remember { mutableStateOf(false) } - var isCompleted by remember { mutableStateOf(false) } - var currentPositionMs by remember { mutableIntStateOf(0) } - var durationMs by remember { mutableIntStateOf(0) } + // UI-only state. Playback state (playing, position, duration, …) lives in the ViewModel so it + // survives configuration changes; the values below are pure presentation concerns. var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(0f) } - var isPrepared by remember { mutableStateOf(false) } - - val mediaPlayer = remember { - MediaPlayer().apply { - setOnPreparedListener { mp -> - durationMs = mp.duration - isPrepared = true - } - setOnCompletionListener { - isPlaying = false - isCompleted = true - } - } - } - - // Initialise the media source - LaunchedEffect(localPath, contentUrl) { - try { - mediaPlayer.reset() - isPrepared = false - when { - localPath != null -> mediaPlayer.setDataSource(localPath) - contentUrl != null -> mediaPlayer.setDataSource(context, Uri.parse(contentUrl)) - else -> return@LaunchedEffect - } - mediaPlayer.prepareAsync() - } catch (_: Exception) { - // handle silently — file may not exist yet - } - } - - // Poll playback position while playing - LaunchedEffect(isPlaying) { - while (isActive && isPlaying) { - if (!isSeeking) { - currentPositionMs = mediaPlayer.currentPosition - } - delay(POSITION_POLL_MS) - } - } - - // Cleanup - DisposableEffect(Unit) { - onDispose { - try { mediaPlayer.stop() } catch (_: Exception) { } - mediaPlayer.release() - } - } fun stopAndBack() { - try { mediaPlayer.stop() } catch (_: Exception) { } + onStop() onNavigateBack() } - fun play() { - if (isPrepared) { - mediaPlayer.start() - isPlaying = true - isCompleted = false - } - } - - fun pause() { - mediaPlayer.pause() - isPlaying = false - } - - fun seekTo(ms: Int) { - mediaPlayer.seekTo(ms) - currentPositionMs = ms - } - - fun togglePlayPause() { - if (isCompleted) { - seekTo(0) - play() - } else if (isPlaying) { - pause() - } else { - play() - } - } - BackHandler { stopAndBack() } WireScaffold( @@ -246,12 +162,12 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.height(dimensions().spacing24x)) // — Animated album art circle - PulsingAlbumArt(isPlaying = isPlaying) + PulsingAlbumArt(isPlaying = state.isPlaying) Spacer(modifier = Modifier.height(dimensions().spacing32x)) // — Equalizer bars - EqualizerBars(isPlaying = isPlaying) + EqualizerBars(isPlaying = state.isPlaying) Spacer(modifier = Modifier.height(dimensions().spacing24x)) @@ -270,8 +186,8 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.height(dimensions().spacing32x)) // — Seek slider - val progress = if (durationMs > 0 && !isSeeking) { - currentPositionMs.toFloat() / durationMs + val progress = if (state.durationMs > 0 && !isSeeking) { + state.currentPositionMs.toFloat() / state.durationMs } else if (isSeeking) { seekProgress } else { @@ -285,9 +201,7 @@ internal fun CellAudioPlayerContent( seekProgress = value }, onValueChangeFinished = { - val targetMs = (seekProgress * durationMs).toInt() - seekTo(targetMs) - currentPositionMs = targetMs + onSeek((seekProgress * state.durationMs).toInt()) isSeeking = false }, colors = SliderDefaults.colors( @@ -306,12 +220,12 @@ internal fun CellAudioPlayerContent( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = currentPositionMs.toTimeString(), + text = state.currentPositionMs.toTimeString(), color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp, ) Text( - text = durationMs.toTimeString(), + text = state.durationMs.toTimeString(), color = Color.White.copy(alpha = 0.5f), fontSize = 12.sp, ) @@ -328,8 +242,7 @@ internal fun CellAudioPlayerContent( // Skip back 15s IconButton( onClick = { - val target = (currentPositionMs - SKIP_MS).coerceAtLeast(0) - seekTo(target) + onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) } ) { Icon( @@ -344,7 +257,7 @@ internal fun CellAudioPlayerContent( // Play / Pause button (large, spring-animated) val buttonScale by animateFloatAsState( - targetValue = if (isPrepared) 1f else 0.7f, + targetValue = if (state.isPrepared) 1f else 0.7f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium, @@ -359,7 +272,7 @@ internal fun CellAudioPlayerContent( .clip(CircleShape) .background(AccentColor) .then( - if (isPrepared) { + if (state.isPrepared) { Modifier.padding(0.dp) } else { Modifier @@ -367,9 +280,9 @@ internal fun CellAudioPlayerContent( ), contentAlignment = Alignment.Center, ) { - val iconRes = if (isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play + val iconRes = if (state.isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play IconButton( - onClick = { if (isPrepared) togglePlayPause() }, + onClick = { if (state.isPrepared) onTogglePlayPause() }, modifier = Modifier.fillMaxSize(), ) { Icon( @@ -386,8 +299,7 @@ internal fun CellAudioPlayerContent( // Skip forward 15s IconButton( onClick = { - val target = (currentPositionMs + SKIP_MS).coerceAtMost(durationMs) - seekTo(target) + onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) } ) { Icon( @@ -511,9 +423,11 @@ private fun Int.toTimeString(): String { fun PreviewCellAudioPlayerScreen() { WireTheme { CellAudioPlayerContent( - localPath = null, - contentUrl = null, + state = AudioPlaybackState(), 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..dd26bdc5028 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt @@ -0,0 +1,145 @@ +/* + * 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 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 CellAudioPlayerViewModel( + @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 + + // Playback state lives here so it survives configuration changes; the screen only renders it and + // forwards user intents back through the action functions below. + 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 { + when { + localPath != null -> mediaPlayer.setDataSource(localPath) + contentUrl != null -> mediaPlayer.setDataSource(context, Uri.parse(contentUrl)) +// else -> return + } + 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 + } + + 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 + } +} + +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/CellAudioPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt deleted file mode 100644 index fdb10e58a63..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModel.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import com.ramcosta.composedestinations.generated.cells.destinations.CellAudioPlayerScreenDestination - -class CellAudioPlayerViewModel( - savedStateHandle: SavedStateHandle, -) : ViewModel() { - private val navArgs = CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) - val localPath: String? = navArgs.localPath - val contentUrl: String? = navArgs.contentUrl - val fileName: String? = navArgs.fileName -} - 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 c42b048e769..13334b52af3 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 @@ -53,8 +53,8 @@ import com.wire.android.feature.cells.ui.CellScreenContent import com.wire.android.feature.cells.ui.CellViewModel 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.audioplayer.CellAudioPlayerNavArgs -import com.wire.android.feature.cells.ui.videoplayer.CellVideoViewerNavArgs +import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs +import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet @@ -276,7 +276,7 @@ fun SearchScreen( navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( - CellVideoViewerNavArgs( + VideoViewerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, @@ -289,7 +289,7 @@ fun SearchScreen( navigator.navigate( NavigationCommand( CellAudioPlayerScreenDestination( - CellAudioPlayerNavArgs( + AudioPlayerNavArgs( localPath = file.localPath, contentUrl = file.contentUrl, fileName = file.name, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt index 01521d18949..a4315de74da 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt @@ -108,7 +108,7 @@ private const val CONTROLS_AUTO_HIDE_MS = 3_000L @WireCellsDestination( style = PopUpNavigationAnimation::class, - navArgs = CellVideoViewerNavArgs::class, + navArgs = VideoViewerNavArgs::class, ) @Composable fun VideoPlayerScreen( diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt index 3eba6bad87e..a7eca996e4a 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt @@ -17,7 +17,6 @@ */ package com.wire.android.feature.cells.ui.videoplayer -import android.app.Application import android.content.Context import android.net.Uri import androidx.lifecycle.SavedStateHandle @@ -43,7 +42,7 @@ class VideoPlayerViewModel( savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val navArgs: CellVideoViewerNavArgs = VideoPlayerScreenDestination.argsFrom(savedStateHandle) + private val navArgs: VideoViewerNavArgs = VideoPlayerScreenDestination.argsFrom(savedStateHandle) val localPath: String? = navArgs.localPath val contentUrl: String? = navArgs.contentUrl diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt similarity index 96% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt rename to features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt index 138b86376f4..b26f79b72bf 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/CellVideoViewerNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt @@ -17,7 +17,7 @@ */ package com.wire.android.feature.cells.ui.videoplayer -data class CellVideoViewerNavArgs( +data class VideoViewerNavArgs( val localPath: String? = null, val contentUrl: String? = null, val fileName: String? = null, From 71723c67887a89a5ca42e0170db8be15ff25a4ed Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 10:24:52 +0100 Subject: [PATCH 07/18] feat: cleanup --- .../ui/audioplayer/AudioPlaybackState.kt | 26 +++++++++++++++++++ .../cells/ui/audioplayer/AudioPlayerScreen.kt | 25 +++++------------- .../ui/audioplayer/AudioPlayerViewModel.kt | 10 ------- 3 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt 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/AudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt index 15067dc93f0..560a29e1aa8 100644 --- 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 @@ -118,8 +118,6 @@ internal fun CellAudioPlayerContent( onNavigateBack: () -> Unit, modifier: Modifier = Modifier, ) { - // UI-only state. Playback state (playing, position, duration, …) lives in the ViewModel so it - // survives configuration changes; the values below are pure presentation concerns. var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(0f) } @@ -161,17 +159,14 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.height(dimensions().spacing24x)) - // — Animated album art circle PulsingAlbumArt(isPlaying = state.isPlaying) Spacer(modifier = Modifier.height(dimensions().spacing32x)) - // — Equalizer bars EqualizerBars(isPlaying = state.isPlaying) Spacer(modifier = Modifier.height(dimensions().spacing24x)) - // — File name Text( text = fileName ?: stringResource(R.string.conversation_files_title), color = Color.White, @@ -185,7 +180,6 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.height(dimensions().spacing32x)) - // — Seek slider val progress = if (state.durationMs > 0 && !isSeeking) { state.currentPositionMs.toFloat() / state.durationMs } else if (isSeeking) { @@ -212,7 +206,6 @@ internal fun CellAudioPlayerContent( modifier = Modifier.fillMaxWidth(), ) - // — Time row Row( modifier = Modifier .fillMaxWidth() @@ -233,13 +226,11 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.height(dimensions().spacing24x)) - // — Controls row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - // Skip back 15s IconButton( onClick = { onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) @@ -255,7 +246,6 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.width(dimensions().spacing24x)) - // Play / Pause button (large, spring-animated) val buttonScale by animateFloatAsState( targetValue = if (state.isPrepared) 1f else 0.7f, animationSpec = spring( @@ -267,7 +257,7 @@ internal fun CellAudioPlayerContent( Box( modifier = Modifier - .size(72.dp) + .size(dimensions().spacing72x) .scale(buttonScale) .clip(CircleShape) .background(AccentColor) @@ -296,7 +286,6 @@ internal fun CellAudioPlayerContent( Spacer(modifier = Modifier.width(dimensions().spacing24x)) - // Skip forward 15s IconButton( onClick = { onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) @@ -306,7 +295,7 @@ internal fun CellAudioPlayerContent( painter = painterResource(R.drawable.ic_cell_skip_forward), contentDescription = null, tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(32.dp), + modifier = Modifier.size(dimensions().spacing32x), ) } } @@ -317,7 +306,6 @@ internal fun CellAudioPlayerContent( } } -// — Animated pulsing album art placeholder @Composable private fun PulsingAlbumArt(isPlaying: Boolean) { val infiniteTransition = rememberInfiniteTransition(label = "albumArtPulse") @@ -334,7 +322,7 @@ private fun PulsingAlbumArt(isPlaying: Boolean) { Box( contentAlignment = Alignment.Center, modifier = Modifier - .size(200.dp) + .size(dimensions().spacing200x) .scale(scale) .clip(CircleShape) .background( @@ -343,7 +331,6 @@ private fun PulsingAlbumArt(isPlaying: Boolean) { ) ), ) { - // Outer glow ring Box( modifier = Modifier .size(190.dp) @@ -388,7 +375,7 @@ private fun EqualizerBars(isPlaying: Boolean) { ) } - val maxBarHeightPx = 32.dp + val maxBarHeightPx = dimensions().spacing32x Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -398,9 +385,9 @@ private fun EqualizerBars(isPlaying: Boolean) { val fraction by heightState Box( modifier = Modifier - .width(5.dp) + .width(dimensions().spacing6x) .height(maxBarHeightPx * fraction) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(3.dp)) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) .background( Brush.verticalGradient( colors = listOf(AccentLight, AccentColor) 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 index dd26bdc5028..f3493b63455 100644 --- 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 @@ -44,8 +44,6 @@ class CellAudioPlayerViewModel( val contentUrl: String? = navArgs.contentUrl val fileName: String? = navArgs.fileName - // Playback state lives here so it survives configuration changes; the screen only renders it and - // forwards user intents back through the action functions below. private val _state = MutableStateFlow(AudioPlaybackState()) val state: StateFlow = _state.asStateFlow() @@ -135,11 +133,3 @@ class CellAudioPlayerViewModel( const val POSITION_POLL_MS = 200L } } - -data class AudioPlaybackState( - val isPlaying: Boolean = false, - val isCompleted: Boolean = false, - val isPrepared: Boolean = false, - val currentPositionMs: Int = 0, - val durationMs: Int = 0, -) From 43c9b31a364a74f94b948167d9ab0198b5794f24 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 8 Jul 2026 15:29:36 +0200 Subject: [PATCH 08/18] feat: cleanup --- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 320 +++++++----------- .../main/res/drawable/ic_cell_skip_back.xml | 20 +- .../res/drawable/ic_cell_skip_forward.xml | 20 +- 3 files changed, 148 insertions(+), 212 deletions(-) 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 index 560a29e1aa8..0fdd3e43e11 100644 --- 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 @@ -18,7 +18,6 @@ package com.wire.android.feature.cells.ui.audioplayer import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring @@ -56,7 +55,6 @@ 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.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -77,13 +75,9 @@ 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.theme.WireTheme +import com.wire.android.util.EMPTY -private const val SKIP_MS = 15_000 - -private val BackgroundTop = Color(0xFF1A1A2E) -private val BackgroundBottom = Color(0xFF0D0D1A) -private val AccentColor = Color(0xFF6C5CE7) -private val AccentLight = Color(0xFFA29BFE) +private const val SKIP_MS = 10_000 @WireCellsDestination( style = PopUpNavigationAnimation::class, @@ -132,228 +126,174 @@ internal fun CellAudioPlayerContent( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - title = fileName ?: stringResource(R.string.conversation_files_title), + title = String.EMPTY, navigationIconType = NavigationIconType.Back(), onNavigationPressed = ::stopAndBack, ) }, ) { innerPadding -> - Box( + Column( modifier = Modifier .padding(innerPadding) .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf(BackgroundTop, BackgroundBottom) - ) - ), - contentAlignment = Alignment.Center, + .background(color = Color.Black) + .padding(horizontal = dimensions().spacing24x), ) { - Column( + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = dimensions().spacing24x), - horizontalAlignment = Alignment.CenterHorizontally, + .weight(1f), + contentAlignment = Alignment.Center, ) { - - Spacer(modifier = Modifier.height(dimensions().spacing24x)) - - PulsingAlbumArt(isPlaying = state.isPlaying) - - Spacer(modifier = Modifier.height(dimensions().spacing32x)) - - EqualizerBars(isPlaying = state.isPlaying) - - Spacer(modifier = Modifier.height(dimensions().spacing24x)) - - Text( - text = fileName ?: stringResource(R.string.conversation_files_title), - color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth(), - ) - - Spacer(modifier = Modifier.height(dimensions().spacing32x)) - - val progress = if (state.durationMs > 0 && !isSeeking) { - state.currentPositionMs.toFloat() / state.durationMs - } else if (isSeeking) { - seekProgress - } else { - 0f - } + ) { + EqualizerBars(isPlaying = state.isPlaying) - Slider( - value = progress, - onValueChange = { value -> - isSeeking = true - seekProgress = value - }, - onValueChangeFinished = { - onSeek((seekProgress * state.durationMs).toInt()) - isSeeking = false - }, - colors = SliderDefaults.colors( - thumbColor = AccentLight, - activeTrackColor = AccentLight, - inactiveTrackColor = Color.White.copy(alpha = 0.25f), - ), - modifier = Modifier.fillMaxWidth(), - ) + Spacer(modifier = Modifier.height(dimensions().spacing24x)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensions().spacing4x), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = state.currentPositionMs.toTimeString(), - color = Color.White.copy(alpha = 0.8f), - fontSize = 12.sp, - ) Text( - text = state.durationMs.toTimeString(), - color = Color.White.copy(alpha = 0.5f), - fontSize = 12.sp, + text = fileName ?: stringResource(R.string.conversation_files_title), + color = Color.White, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) } + } - Spacer(modifier = Modifier.height(dimensions().spacing24x)) + 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 = Color.White, + activeTrackColor = Color.White, + inactiveTrackColor = Color.White.copy(alpha = 0.25f), + ), + modifier = Modifier.fillMaxWidth(), + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing4x), + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - painter = painterResource(R.drawable.ic_cell_skip_back), - contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(32.dp), + Text( + text = state.currentPositionMs.toTimeString(), + color = Color.White, + fontSize = 12.sp, + ) + Text( + text = state.durationMs.toTimeString(), + color = Color.White, + fontSize = 12.sp, ) } - 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", - ) + Spacer(modifier = Modifier.height(dimensions().spacing24x)) - Box( - modifier = Modifier - .size(dimensions().spacing72x) - .scale(buttonScale) - .clip(CircleShape) - .background(AccentColor) - .then( - if (state.isPrepared) { - Modifier.padding(0.dp) - } else { - Modifier - } - ), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - val iconRes = if (state.isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play IconButton( - onClick = { if (state.isPrepared) onTogglePlayPause() }, - modifier = Modifier.fillMaxSize(), + onClick = { + onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) + } ) { Icon( - painter = painterResource(iconRes), + painter = painterResource(R.drawable.ic_cell_skip_back), contentDescription = null, - tint = Color.White, - modifier = Modifier.size(40.dp), + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(dimensions().spacing32x), ) } - } - Spacer(modifier = Modifier.width(dimensions().spacing24x)) + Spacer(modifier = Modifier.width(dimensions().spacing24x)) - IconButton( - onClick = { - onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) - } - ) { - Icon( - painter = painterResource(R.drawable.ic_cell_skip_forward), - contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(dimensions().spacing32x), + 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().spacing72x) + .scale(buttonScale) + .clip(CircleShape) + .background(Color.White) + .then( + if (state.isPrepared) { + Modifier.padding(0.dp) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + val iconRes = if (state.isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play + IconButton( + onClick = { if (state.isPrepared) onTogglePlayPause() }, + modifier = Modifier.fillMaxSize(), + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = Color.Black, + modifier = Modifier.size(40.dp), + ) + } + } + + Spacer(modifier = Modifier.width(dimensions().spacing24x)) + + IconButton( + onClick = { + onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_cell_skip_forward), + contentDescription = null, + tint = Color.White.copy(alpha = 0.75f), + modifier = Modifier.size(dimensions().spacing32x), + ) + } + } Spacer(modifier = Modifier.height(dimensions().spacing32x)) } } } } -@Composable -private fun PulsingAlbumArt(isPlaying: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "albumArtPulse") - val scale by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = if (isPlaying) 1.06f else 1f, - animationSpec = infiniteRepeatable( - animation = tween(900, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "albumArtScale", - ) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(dimensions().spacing200x) - .scale(scale) - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf(AccentColor, BackgroundTop), - ) - ), - ) { - Box( - modifier = Modifier - .size(190.dp) - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf( - AccentLight.copy(alpha = 0.15f), - Color.Transparent, - ) - ) - ) - ) - Icon( - painter = painterResource(R.drawable.ic_file_type_audio), - contentDescription = null, - tint = Color.White.copy(alpha = 0.9f), - modifier = Modifier.size(88.dp), - ) - } -} - -// — Animated equalizer bars @Composable private fun EqualizerBars(isPlaying: Boolean) { val barCount = 7 @@ -388,11 +328,7 @@ private fun EqualizerBars(isPlaying: Boolean) { .width(dimensions().spacing6x) .height(maxBarHeightPx * fraction) .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) - .background( - Brush.verticalGradient( - colors = listOf(AccentLight, AccentColor) - ) - ) + .background(Color.White) ) } } diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_back.xml b/features/cells/src/main/res/drawable/ic_cell_skip_back.xml index ea73bdc16ae..b73397f1ec4 100644 --- a/features/cells/src/main/res/drawable/ic_cell_skip_back.xml +++ b/features/cells/src/main/res/drawable/ic_cell_skip_back.xml @@ -16,18 +16,18 @@ ~ along with this program. If not, see http://www.gnu.org/licenses/. --> + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#FFFFFF" + android:pathData="M10.8,14.8H10.3V11.3L9.8,11.5V11L10.7,10.7H10.8V14.8Z" /> + android:fillColor="#FFFFFF" + android:pathData="M14.3,12.8C14.3,14.1 13.9,15 13,15C12.1,15 11.7,14.1 11.7,12.8C11.7,11.5 12.1,10.6 13,10.6C13.9,10.6 14.3,11.5 14.3,12.8ZM12.3,12.8C12.3,13.7 12.5,14.3 13,14.3C13.5,14.3 13.7,13.7 13.7,12.8C13.7,11.9 13.5,11.3 13,11.3C12.5,11.3 12.3,11.9 12.3,12.8Z" /> + android:fillColor="#FFFFFF" + android:pathData="M11.99,5V1l-5,5 5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6 -6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8 -3.58,-8 -8,-8z" /> diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml b/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml index 02e4f2ee56e..5a7667b6f5b 100644 --- a/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml +++ b/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml @@ -16,18 +16,18 @@ ~ along with this program. If not, see http://www.gnu.org/licenses/. --> + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#FFFFFF" + android:pathData="M10.8,14.8H10.3V11.3L9.8,11.5V11L10.7,10.7H10.8V14.8Z" /> + android:fillColor="#FFFFFF" + android:pathData="M14.3,12.8C14.3,14.1 13.9,15 13,15C12.1,15 11.7,14.1 11.7,12.8C11.7,11.5 12.1,10.6 13,10.6C13.9,10.6 14.3,11.5 14.3,12.8ZM12.3,12.8C12.3,13.7 12.5,14.3 13,14.3C13.5,14.3 13.7,13.7 13.7,12.8C13.7,11.9 13.5,11.3 13,11.3C12.5,11.3 12.3,11.9 12.3,12.8Z" /> + android:fillColor="#FFFFFF" + android:pathData="M12.01,5V1l5,5 -5,5V7c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6h2c0,4.42 -3.58,8 -8,8s-8,-3.58 -8,-8 3.58,-8 8,-8z" /> From a79df039a1339d4f81dae5b8ef9309f62905c516 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 8 Jul 2026 15:51:37 +0200 Subject: [PATCH 09/18] feat: play audio by uri --- .../cells/ui/audioplayer/AudioPlayerViewModel.kt | 15 ++++++++++----- .../android/feature/cells/ui/model/CellNodeUi.kt | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) 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 index f3493b63455..0e92e48c184 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -61,12 +62,10 @@ class CellAudioPlayerViewModel( init { try { - when { - localPath != null -> mediaPlayer.setDataSource(localPath) - contentUrl != null -> mediaPlayer.setDataSource(context, Uri.parse(contentUrl)) -// else -> return + audioUri()?.let { uri -> + mediaPlayer.setDataSource(context, uri) + mediaPlayer.prepareAsync() } - mediaPlayer.prepareAsync() } catch (_: Exception) { // handle silently — file may not exist yet } @@ -118,6 +117,12 @@ class CellAudioPlayerViewModel( 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() 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 From 255f4e13c4cdd552d3ce6fc56c81d84824c36e9a Mon Sep 17 00:00:00 2001 From: ohassine Date: Mon, 13 Jul 2026 17:03:59 +0200 Subject: [PATCH 10/18] feat(drive): cleanup --- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 1 - .../CellAudioPlayerViewModelTest.kt | 348 ++++++++++++++++++ 2 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt 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 index 0fdd3e43e11..55d38d198d2 100644 --- 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 @@ -355,4 +355,3 @@ fun PreviewCellAudioPlayerScreen() { ) } } - diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt new file mode 100644 index 00000000000..b8152af6936 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.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 CellAudioPlayerViewModelTest { + + @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 CellAudioPlayerViewModel(context, savedStateHandle) + } + } +} From 2c7603a168166454094e80906e40d492a9eaf503 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 14 Jul 2026 13:00:04 +0200 Subject: [PATCH 11/18] feat(drive): detekt --- .../com/wire/android/feature/cells/ui/AllFilesScreen.kt | 5 ++--- .../java/com/wire/android/feature/cells/ui/CellViewModel.kt | 1 + .../android/feature/cells/ui/CellsMetroViewModelBindings.kt | 4 ++-- .../wire/android/feature/cells/ui/CellsViewModelFactory.kt | 6 +++--- .../wire/android/feature/cells/ui/CellsViewModelGraph.kt | 4 ++-- .../feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt | 1 - .../feature/cells/ui/audioplayer/AudioPlayerScreen.kt | 3 ++- .../feature/cells/ui/audioplayer/AudioPlayerViewModel.kt | 2 +- .../wire/android/feature/cells/ui/search/SearchScreen.kt | 4 +--- .../com/wire/android/feature/cells/ui/CellViewModelTest.kt | 1 - ...ioPlayerViewModelTest.kt => AudioPlayerViewModelTest.kt} | 6 +++--- 11 files changed, 17 insertions(+), 20 deletions(-) rename features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/{CellAudioPlayerViewModelTest.kt => AudioPlayerViewModelTest.kt} (98%) 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 05a812c8228..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,15 +28,14 @@ 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.CellImageViewerScreenDestination 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.common.OfflineBanner -import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs 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 import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs 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 a20db03531f..3bdd25fbbb5 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 -> { 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 cee24051f0b..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,7 +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.CellAudioPlayerViewModel +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 @@ -133,7 +133,7 @@ object CellsMetroViewModelBindings { @Provides @IntoMap - @ViewModelAssistedFactoryKey(CellAudioPlayerViewModel::class) + @ViewModelAssistedFactoryKey(AudioPlayerViewModel::class) fun audioPlayerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = savedStateViewModel { factory.cellAudioPlayerViewModel( 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 cc63aaae81e..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,7 +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.CellAudioPlayerViewModel +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 @@ -74,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, @@ -234,7 +234,7 @@ class CellsViewModelFactory @Inject constructor( internal fun cellAudioPlayerViewModel( context: Context, savedStateHandle: SavedStateHandle - ) = CellAudioPlayerViewModel( + ) = 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 e8cbfe0752c..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,7 +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.CellAudioPlayerViewModel +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 @@ -97,4 +97,4 @@ fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() fun cellVideoViewerViewModel(): VideoPlayerViewModel = cellsViewModel() @Composable -fun cellAudioPlayerViewModel(): CellAudioPlayerViewModel = cellsViewModel() +fun cellAudioPlayerViewModel(): AudioPlayerViewModel = cellsViewModel() 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 index c8a9173ee4f..47f43aaea93 100644 --- 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 @@ -22,4 +22,3 @@ data class AudioPlayerNavArgs( 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 index 55d38d198d2..d00b7b37c62 100644 --- 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 @@ -87,7 +87,7 @@ private const val SKIP_MS = 10_000 fun CellAudioPlayerScreen( navigator: WireNavigator, modifier: Modifier = Modifier, - viewModel: CellAudioPlayerViewModel = cellAudioPlayerViewModel(), + viewModel: AudioPlayerViewModel = cellAudioPlayerViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() CellAudioPlayerContent( @@ -334,6 +334,7 @@ private fun EqualizerBars(isPlaying: Boolean) { } } +@Suppress("MagicNumber") private fun Int.toTimeString(): String { val totalSec = this / 1000 val min = totalSec / 60 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 index 0e92e48c184..935ad197396 100644 --- 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 @@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -class CellAudioPlayerViewModel( +class AudioPlayerViewModel( @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, ) : ViewModel() { 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 8887a9082a2..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 @@ -43,7 +43,6 @@ import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTa 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 @@ -52,10 +51,9 @@ 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.audioplayer.AudioPlayerNavArgs -import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.search.filter.FilterChipsRow import com.wire.android.feature.cells.ui.search.filter.bottomsheet.FilterByTypeBottomSheet diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index 64aa045d45e..fe3574046d6 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -26,7 +26,6 @@ import app.cash.turbine.test import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination import com.wire.android.config.NavigationTestExtension -import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.edit.OnlineEditor import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.ui.model.toUiModel diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt similarity index 98% rename from features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt rename to features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt index b8152af6936..bc8a6f18809 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/CellAudioPlayerViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt @@ -44,7 +44,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) -class CellAudioPlayerViewModelTest { +class AudioPlayerViewModelTest { @AfterEach fun tearDown() { @@ -340,9 +340,9 @@ class CellAudioPlayerViewModelTest { method.invoke(viewModel) } - fun arrange(): Pair { + fun arrange(): Pair { every { CellAudioPlayerScreenDestination.argsFrom(savedStateHandle) } returns navArgs - return this to CellAudioPlayerViewModel(context, savedStateHandle) + return this to AudioPlayerViewModel(context, savedStateHandle) } } } From 642d3fe651186ecf7e3661ca38f10b57dbc8f62d Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 14 Jul 2026 13:02:59 +0200 Subject: [PATCH 12/18] feat(drive): detekt --- .../main/java/com/wire/android/feature/cells/ui/CellViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 3bdd25fbbb5..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 @@ -407,6 +407,7 @@ class CellViewModel( } } + @Suppress("ReturnCount") private fun openLocalFile(file: CellNodeUi.File) { when (file.assetType) { AttachmentFileType.IMAGE -> { From a0ec516ee3ea107116f942e5bf07febc52e65e27 Mon Sep 17 00:00:00 2001 From: ohassine Date: Tue, 14 Jul 2026 14:14:01 +0200 Subject: [PATCH 13/18] feat(drive): add screen title --- .../android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index d00b7b37c62..0758b3a97a4 100644 --- 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 @@ -75,7 +75,6 @@ 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.theme.WireTheme -import com.wire.android.util.EMPTY private const val SKIP_MS = 10_000 @@ -126,7 +125,7 @@ internal fun CellAudioPlayerContent( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - title = String.EMPTY, + title = fileName ?: stringResource(R.string.conversation_files_title), navigationIconType = NavigationIconType.Back(), onNavigationPressed = ::stopAndBack, ) From f8db60d3edffe5e77968270c58630e59f7a4cf8e Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 22 Jul 2026 13:59:01 +0200 Subject: [PATCH 14/18] chore: address comments --- app/stability/app-devDebug.stability | 17 +++++++++++++---- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 15 +++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index d38d1760737..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) @@ -10239,18 +10239,18 @@ private fun com.wire.android.ui.newauthentication.login.EnterBackendNameDialog(o - onDismiss: STABLE (function type) @Composable -private fun com.wire.android.ui.newauthentication.login.LoginContent(loginEmailSSOState: com.wire.android.ui.newauthentication.login.NewLoginScreenState, userIdentifierState: androidx.compose.foundation.text.input.TextFieldState, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links, onNextClicked: kotlin.Function0, onBackendConfigLinkEntered: kotlin.Function1, onBackendConfigSuccessContinue: kotlin.Function0, onNoBackendSelected: kotlin.Function0, canNavigateBack: kotlin.Boolean, navigateBack: kotlin.Function0): kotlin.Unit +private fun com.wire.android.ui.newauthentication.login.LoginContent(loginEmailSSOState: com.wire.android.ui.newauthentication.login.NewLoginScreenState, userIdentifierState: androidx.compose.foundation.text.input.TextFieldState, serverConfig: com.wire.kalium.logic.configuration.server.ServerConfig.Links, canNavigateBack: kotlin.Boolean, onNextClicked: kotlin.Function0, onBackendConfigLinkEntered: kotlin.Function1, onBackendConfigSuccessContinue: kotlin.Function0, onNoBackendSelected: kotlin.Function0, navigateBack: kotlin.Function0): kotlin.Unit skippable: false restartable: true params: - loginEmailSSOState: STABLE (class with no mutable properties) - userIdentifierState: STABLE (marked @Stable or @Immutable) - serverConfig: UNSTABLE (has mutable properties or unstable members) + - canNavigateBack: STABLE (primitive type) - onNextClicked: STABLE (function type) - onBackendConfigLinkEntered: STABLE (function type) - onBackendConfigSuccessContinue: STABLE (function type) - onNoBackendSelected: STABLE (function type) - - canNavigateBack: STABLE (primitive type) - navigateBack: STABLE (function type) @Composable @@ -10535,6 +10535,15 @@ public fun com.wire.android.ui.registration.selector.CreateAccountSelectorScreen - navigator: STABLE (marked @Stable or @Immutable) - viewModel: UNSTABLE (has mutable properties or unstable members) +@Composable +internal fun com.wire.android.ui.rememberWireActivityNavigator(isUserUiBlocked: kotlin.Boolean, finish: kotlin.Function0, isAllowedToNavigate: kotlin.Function1): com.wire.android.navigation.Navigator + skippable: true + restartable: true + params: + - isUserUiBlocked: STABLE (primitive type) + - finish: STABLE (function type) + - isAllowedToNavigate: STABLE (function type) + @Composable private fun com.wire.android.ui.server.ApiVersioningDialog(title: kotlin.String, text: kotlin.String, actionText: kotlin.String, onDismiss: kotlin.Function0, onAction: kotlin.Function0, isPreview: kotlin.Boolean): kotlin.Unit skippable: true 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 index 0758b3a97a4..681818b4059 100644 --- 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 @@ -58,22 +58,22 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.darkColorsScheme 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 private const val SKIP_MS = 10_000 @@ -136,7 +136,7 @@ internal fun CellAudioPlayerContent( modifier = Modifier .padding(innerPadding) .fillMaxSize() - .background(color = Color.Black) + .background(color = darkColorsScheme().background) .padding(horizontal = dimensions().spacing24x), ) { Box( @@ -156,10 +156,9 @@ internal fun CellAudioPlayerContent( Text( text = fileName ?: stringResource(R.string.conversation_files_title), color = Color.White, - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center, maxLines = 2, + style = typography().title02, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth(), ) @@ -203,12 +202,12 @@ internal fun CellAudioPlayerContent( Text( text = state.currentPositionMs.toTimeString(), color = Color.White, - fontSize = 12.sp, + style = typography().subline01, ) Text( text = state.durationMs.toTimeString(), color = Color.White, - fontSize = 12.sp, + style = typography().subline01, ) } @@ -267,7 +266,7 @@ internal fun CellAudioPlayerContent( painter = painterResource(iconRes), contentDescription = null, tint = Color.Black, - modifier = Modifier.size(40.dp), + modifier = Modifier.size(dimensions().spacing40x), ) } } From 03b37086a68a24cd775a2333724918070269c1e4 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 22 Jul 2026 14:50:31 +0200 Subject: [PATCH 15/18] chore: compose stability --- .../cells/stability/cells-debug.stability | 50 +++++++++++++++++-- .../stability/meetings-debug.stability | 25 +++------- 2 files changed, 54 insertions(+), 21 deletions(-) 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 diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index bfb567cc999..75ad414ab2e 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -80,13 +80,12 @@ private fun com.wire.android.feature.meetings.ui.create.ParticipantsInput(partic - onClick: STABLE (function type) @Composable -private fun com.wire.android.feature.meetings.ui.create.RepeatingIntervalDropDown(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval, onRepeatingIntervalChanged: kotlin.Function1, items: kotlin.collections.List): kotlin.Unit - skippable: false +private fun com.wire.android.feature.meetings.ui.create.RepeatingIntervalDropDown(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval?, onRepeatingIntervalChanged: kotlin.Function1): kotlin.Unit + skippable: true restartable: true params: - repeatingInterval: STABLE (marked @Stable or @Immutable) - onRepeatingIntervalChanged: STABLE (function type) - - items: RUNTIME (requires runtime check) @Composable private fun com.wire.android.feature.meetings.ui.create.SelectButton(onClick: kotlin.Function0, modifier: androidx.compose.ui.Modifier, buttonModifier: androidx.compose.ui.Modifier, leadingIcon: @[Composable] androidx.compose.runtime.internal.ComposableFunction0?, elevation: androidx.compose.ui.unit.Dp): kotlin.Unit @@ -185,10 +184,11 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingItemDivider(): kotl params: @Composable -internal fun com.wire.android.feature.meetings.ui.list.MeetingLeadingIcon(): kotlin.Unit +internal fun com.wire.android.feature.meetings.ui.list.MeetingLeadingIcon(padding: androidx.compose.foundation.layout.PaddingValues): kotlin.Unit skippable: true restartable: true params: + - padding: STABLE (marked @Stable or @Immutable) @Composable public fun com.wire.android.feature.meetings.ui.list.MeetingList(type: com.wire.android.feature.meetings.ui.MeetingsTabItem, modifier: androidx.compose.ui.Modifier, contentPadding: androidx.compose.foundation.layout.PaddingValues, lazyListState: androidx.compose.foundation.lazy.LazyListState, openMeetingOptions: kotlin.Function1<@[ParameterName(name = \, startCall: kotlin.Function1<@[ParameterName(name = \, joinCall: kotlin.Function1<@[ParameterName(name = \, returnToCall: kotlin.Function1<@[ParameterName(name = \): kotlin.Unit @@ -236,14 +236,6 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingOngoingDurationTime params: - startedTime: STABLE (matched by stability configuration) -@Composable -public fun com.wire.android.feature.meetings.ui.list.MeetingShowAllFooter(onShowAll: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - onShowAll: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - @Composable private fun com.wire.android.feature.meetings.ui.list.MeetingStartingInPrimaryBodyText(startTime: kotlinx.datetime.Instant): kotlin.Unit skippable: true @@ -252,7 +244,7 @@ private fun com.wire.android.feature.meetings.ui.list.MeetingStartingInPrimaryBo - startTime: STABLE (matched by stability configuration) @Composable -private fun com.wire.android.feature.meetings.ui.list.MeetingTimeInfoRow(status: com.wire.android.feature.meetings.model.MeetingItem.Status, repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.list.MeetingTimeInfoRow(status: com.wire.android.feature.meetings.model.MeetingItem.Status, repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval?): kotlin.Unit skippable: true restartable: true params: @@ -267,7 +259,7 @@ private fun com.wire.android.feature.meetings.ui.list.PrimaryBodyText(text: kotl - text: STABLE (String is immutable) @Composable -private fun com.wire.android.feature.meetings.ui.list.RepeatingIntervalInfoLabel(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.list.RepeatingIntervalInfoLabel(repeatingInterval: com.wire.android.feature.meetings.model.MeetingItem.RepeatingInterval?): kotlin.Unit skippable: true restartable: true params: @@ -323,12 +315,11 @@ public fun com.wire.android.feature.meetings.ui.newMeetingViewModel(viewModelSto - viewModelStoreOwner: RUNTIME (requires runtime check) @Composable -private fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalContent(meeting: com.wire.android.feature.meetings.model.MeetingItem, onStartMeeting: kotlin.Function0, onCreateConversation: kotlin.Function0, onCopyLink: kotlin.Function0, onEditMeeting: kotlin.Function0, onDeleteMeetingForMe: kotlin.Function0, onDeleteMeetingForEveryone: kotlin.Function0): kotlin.Unit +private fun com.wire.android.feature.meetings.ui.options.MeetingOptionsModalContent(meetingState: com.wire.android.feature.meetings.ui.options.MeetingOptionsMenuState.Meeting, onCreateConversation: kotlin.Function0, onCopyLink: kotlin.Function0, onEditMeeting: kotlin.Function0, onDeleteMeetingForMe: kotlin.Function0, onDeleteMeetingForEveryone: kotlin.Function0): kotlin.Unit skippable: true restartable: true params: - - meeting: STABLE (marked @Stable or @Immutable) - - onStartMeeting: STABLE (function type) + - meetingState: STABLE (class with no mutable properties) - onCreateConversation: STABLE (function type) - onCopyLink: STABLE (function type) - onEditMeeting: STABLE (function type) From f676753eec026c9a5421e0731d2318c577df2da5 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 22 Jul 2026 16:42:40 +0200 Subject: [PATCH 16/18] chore: remove file name --- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) 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 index 681818b4059..897872bec75 100644 --- 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 @@ -145,24 +145,7 @@ internal fun CellAudioPlayerContent( .weight(1f), contentAlignment = Alignment.Center, ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - EqualizerBars(isPlaying = state.isPlaying) - - Spacer(modifier = Modifier.height(dimensions().spacing24x)) - - Text( - text = fileName ?: stringResource(R.string.conversation_files_title), - color = Color.White, - textAlign = TextAlign.Center, - maxLines = 2, - style = typography().title02, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) - } + EqualizerBars(isPlaying = state.isPlaying) } val progress = if (state.durationMs > 0 && !isSeeking) { From 3db87e6e2e1c48fe6a0e854b08003c1a7dd65fef Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 22 Jul 2026 17:06:49 +0200 Subject: [PATCH 17/18] chore: detekt --- .../android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt | 2 -- 1 file changed, 2 deletions(-) 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 index 897872bec75..17fbe42492e 100644 --- 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 @@ -58,8 +58,6 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.wire.android.feature.cells.R From f3010093e56a457675e663c8190efaba92be49f4 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 24 Jul 2026 18:16:17 +0200 Subject: [PATCH 18/18] feat: design changes and cleanup --- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 87 +++++++++++-------- .../main/res/drawable/ic_cell_fullscreen.xml | 27 ------ .../res/drawable/ic_cell_fullscreen_exit.xml | 27 ------ .../src/main/res/drawable/ic_cell_pause.xml | 28 ------ .../src/main/res/drawable/ic_cell_play.xml | 28 ------ .../src/main/res/drawable/ic_cell_replay.xml | 28 ------ .../main/res/drawable/ic_cell_skip_back.xml | 33 ------- .../res/drawable/ic_cell_skip_forward.xml | 33 ------- .../main/res/drawable/ic_cell_volume_off.xml | 27 ------ .../main/res/drawable/ic_cell_volume_on.xml | 27 ------ .../src/main/res/drawable/ic_skip_back.xml | 15 ++++ .../src/main/res/drawable/ic_skip_forward.xml | 10 +++ 12 files changed, 74 insertions(+), 296 deletions(-) delete mode 100644 features/cells/src/main/res/drawable/ic_cell_fullscreen.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_pause.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_play.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_replay.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_skip_back.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_skip_forward.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_volume_off.xml delete mode 100644 features/cells/src/main/res/drawable/ic_cell_volume_on.xml create mode 100644 features/cells/src/main/res/drawable/ic_skip_back.xml create mode 100644 features/cells/src/main/res/drawable/ic_skip_forward.xml 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 index 17fbe42492e..6a83150b894 100644 --- 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 @@ -42,6 +42,7 @@ 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 @@ -55,17 +56,15 @@ 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.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp 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.darkColorsScheme +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 @@ -73,8 +72,10 @@ 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_MS = 10_000 +private const val SKIP_S = 12 +private val SKIP_MS = SKIP_S.milliseconds.inWholeMilliseconds.toInt() @WireCellsDestination( style = PopUpNavigationAnimation::class, @@ -134,7 +135,7 @@ internal fun CellAudioPlayerContent( modifier = Modifier .padding(innerPadding) .fillMaxSize() - .background(color = darkColorsScheme().background) + .background(color = colorsScheme().background) .padding(horizontal = dimensions().spacing24x), ) { Box( @@ -167,9 +168,9 @@ internal fun CellAudioPlayerContent( isSeeking = false }, colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - inactiveTrackColor = Color.White.copy(alpha = 0.25f), + thumbColor = colorsScheme().inverseSurface, + activeTrackColor = colorsScheme().inverseSurface, + inactiveTrackColor = colorsScheme().surfaceContainerHighest, ), modifier = Modifier.fillMaxWidth(), ) @@ -182,12 +183,12 @@ internal fun CellAudioPlayerContent( ) { Text( text = state.currentPositionMs.toTimeString(), - color = Color.White, + color = colorsScheme().inverseSurface, style = typography().subline01, ) Text( text = state.durationMs.toTimeString(), - color = Color.White, + color = colorsScheme().inverseSurface, style = typography().subline01, ) } @@ -204,12 +205,20 @@ internal fun CellAudioPlayerContent( onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) } ) { - Icon( - painter = painterResource(R.drawable.ic_cell_skip_back), - contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(dimensions().spacing32x), - ) + 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)) @@ -225,28 +234,23 @@ internal fun CellAudioPlayerContent( Box( modifier = Modifier - .size(dimensions().spacing72x) + .size(dimensions().spacing64x) .scale(buttonScale) - .clip(CircleShape) - .background(Color.White) - .then( - if (state.isPrepared) { - Modifier.padding(0.dp) - } else { - Modifier - } - ), + .clip(CircleShape), contentAlignment = Alignment.Center, ) { - val iconRes = if (state.isPlaying) R.drawable.ic_cell_pause else R.drawable.ic_cell_play + 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 = Color.Black, + tint = colorsScheme().inverseOnSurface, modifier = Modifier.size(dimensions().spacing40x), ) } @@ -259,12 +263,19 @@ internal fun CellAudioPlayerContent( onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) } ) { - Icon( - painter = painterResource(R.drawable.ic_cell_skip_forward), - contentDescription = null, - tint = Color.White.copy(alpha = 0.75f), - modifier = Modifier.size(dimensions().spacing32x), - ) + 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)) @@ -294,9 +305,9 @@ private fun EqualizerBars(isPlaying: Boolean) { ) } - val maxBarHeightPx = dimensions().spacing32x + val maxBarHeightPx = dimensions().spacing24x Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing4x), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.height(maxBarHeightPx), ) { @@ -307,7 +318,7 @@ private fun EqualizerBars(isPlaying: Boolean) { .width(dimensions().spacing6x) .height(maxBarHeightPx * fraction) .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) - .background(Color.White) + .background(colorsScheme().inverseSurface) ) } } @@ -326,7 +337,7 @@ private fun Int.toTimeString(): String { fun PreviewCellAudioPlayerScreen() { WireTheme { CellAudioPlayerContent( - state = AudioPlaybackState(), + state = AudioPlaybackState(isPlaying = true, isPrepared = true, currentPositionMs = 30000, durationMs = 120000), fileName = "awesome_track.mp3", onTogglePlayPause = {}, onSeek = {}, diff --git a/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml b/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml deleted file mode 100644 index a9346240281..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_fullscreen.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml b/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml deleted file mode 100644 index 652375d0ddb..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_pause.xml b/features/cells/src/main/res/drawable/ic_cell_pause.xml deleted file mode 100644 index bd19f84957b..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_pause.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/features/cells/src/main/res/drawable/ic_cell_play.xml b/features/cells/src/main/res/drawable/ic_cell_play.xml deleted file mode 100644 index 39d8a025608..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_play.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/features/cells/src/main/res/drawable/ic_cell_replay.xml b/features/cells/src/main/res/drawable/ic_cell_replay.xml deleted file mode 100644 index 8dab22ea820..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_replay.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_back.xml b/features/cells/src/main/res/drawable/ic_cell_skip_back.xml deleted file mode 100644 index b73397f1ec4..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_skip_back.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - diff --git a/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml b/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml deleted file mode 100644 index 5a7667b6f5b..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_skip_forward.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_off.xml b/features/cells/src/main/res/drawable/ic_cell_volume_off.xml deleted file mode 100644 index ab7e71e81c4..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_volume_off.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_on.xml b/features/cells/src/main/res/drawable/ic_cell_volume_on.xml deleted file mode 100644 index 0c6eb40e34c..00000000000 --- a/features/cells/src/main/res/drawable/ic_cell_volume_on.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - \ No newline at end of file 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 @@ + + +