From 5ff36d608c09cf85bf0d43a1923e112b9db4cb43 Mon Sep 17 00:00:00 2001 From: ohassine Date: Wed, 13 May 2026 19:46:44 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 60b18c0fcc3baf7b261424d4ce28d512e5f07385 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 12:06:10 +0100 Subject: [PATCH 08/11] feat: cleanup --- .../feature/cells/ui/AllFilesScreen.kt | 19 +- .../feature/cells/ui/CellScreenContent.kt | 6 +- .../android/feature/cells/ui/CellViewModel.kt | 19 +- .../cells/ui/CellsMetroViewModelBindings.kt | 14 - .../feature/cells/ui/CellsViewModelFactory.kt | 9 - .../feature/cells/ui/CellsViewModelGraph.kt | 4 - .../cells/ui/ConversationFilesScreen.kt | 17 +- .../ui/audioplayer/AudioPlaybackState.kt | 26 -- .../ui/audioplayer/AudioPlayerNavArgs.kt | 25 -- .../cells/ui/audioplayer/AudioPlayerScreen.kt | 422 ------------------ .../ui/audioplayer/AudioPlayerViewModel.kt | 135 ------ .../feature/cells/ui/search/SearchScreen.kt | 21 +- 12 files changed, 15 insertions(+), 702 deletions(-) delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt delete mode 100644 features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.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 da3dcaa757b..317fea95e76 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 @@ -29,16 +29,14 @@ 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.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.imageviewer.CellImageViewerNavArgs import com.wire.android.feature.cells.ui.search.DriveSearchScreenType +import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator import com.wire.android.ui.common.scaffold.WireScaffold @@ -137,7 +135,7 @@ fun AllFilesScreen( ) ) }, - showVideoViewer = { file -> + showVideoPlayer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -150,19 +148,6 @@ fun AllFilesScreen( ) ) }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, fileReadyFlow = viewModel.fileReadyFlow, ) } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellScreenContent.kt index 16ca8f4d8fd..e2d6da13710 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 @@ -100,8 +100,7 @@ internal fun CellScreenContent( retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, showImageViewer: (CellNodeUi.File) -> Unit = {}, - showVideoViewer: (CellNodeUi.File) -> Unit = {}, - showAudioPlayer: (CellNodeUi.File) -> Unit = {}, + showVideoPlayer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -259,8 +258,7 @@ internal fun CellScreenContent( ).show() } is OpenImageViewer -> showImageViewer(action.file) - is OpenVideoViewer -> showVideoViewer(action.file) - is OpenAudioPlayer -> showAudioPlayer(action.file) + is OpenVideoPlayer -> showVideoPlayer(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 1a31bb3a13d..36bed1c0166 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 @@ -376,14 +376,12 @@ class CellViewModel( sendAction(OpenImageViewer(file)) return } + AttachmentFileType.VIDEO -> { - sendAction(OpenVideoViewer(file)) - return - } - AttachmentFileType.AUDIO -> { - sendAction(OpenAudioPlayer(file)) + sendAction(OpenVideoPlayer(file)) return } + else -> Unit } file.contentUrl?.let { url -> @@ -403,14 +401,12 @@ class CellViewModel( sendAction(OpenImageViewer(file)) return } + AttachmentFileType.VIDEO -> { - sendAction(OpenVideoViewer(file)) - return - } - AttachmentFileType.AUDIO -> { - sendAction(OpenAudioPlayer(file)) + sendAction(OpenVideoPlayer(file)) return } + else -> Unit } file.localPath?.let { path -> @@ -660,8 +656,7 @@ internal data class OpenFolder(val path: String, val title: String, val parentFo internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction internal data object ShowOfflineFileSaved : CellViewAction internal data class OpenImageViewer(val file: CellNodeUi.File) : CellViewAction -internal data class OpenVideoViewer(val file: CellNodeUi.File) : CellViewAction -internal data class OpenAudioPlayer(val file: CellNodeUi.File) : CellViewAction +internal data class OpenVideoPlayer(val file: CellNodeUi.File) : CellViewAction internal enum class CellError(val message: Int) { NO_APP_FOUND(R.string.no_app_found), diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt index 04562346541..4b375fbd558 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 @@ -21,7 +21,6 @@ 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 @@ -128,19 +127,6 @@ object CellsMetroViewModelBindings { ) } - @Provides - @IntoMap - @ViewModelAssistedFactoryKey(CellAudioPlayerViewModel::class) - fun audioPlayerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = - savedStateViewModel { - factory.cellAudioPlayerViewModel( - context = checkNotNull(it[APPLICATION_KEY]) { - "No Application was provided via CreationExtras" - }, - savedStateHandle = it.createSavedStateHandle(), - ) - } - private fun savedStateViewModel(create: (CreationExtras) -> ViewModel): ViewModelAssistedFactory = object : ViewModelAssistedFactory { override fun create(extras: CreationExtras): ViewModel = create(extras) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelFactory.kt index 201a0a96368..557be06c8bf 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,6 @@ 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 @@ -225,12 +224,4 @@ class CellsViewModelFactory @Inject constructor( context = context, savedStateHandle = savedStateHandle, ) - - 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/CellsViewModelGraph.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt index 703ac751a56..66b83287ea4 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,7 +22,6 @@ 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 @@ -92,6 +91,3 @@ fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() @Composable 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 2257afb2a74..5b930e8e1da 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.CellAudioPlayerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination @@ -59,7 +58,6 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.domain.model.AttachmentFileType -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs @@ -382,7 +380,7 @@ internal fun ConversationFilesScreenContent( ) ) }, - showVideoViewer = { file -> + showVideoPlayer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -395,19 +393,6 @@ internal fun ConversationFilesScreenContent( ) ) }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, retryEditNodeError = { retryEditNodeError(it) }, isRefreshing = isRefreshing, onRefresh = onRefresh, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt deleted file mode 100644 index 83cb0dc1194..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt +++ /dev/null @@ -1,26 +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 - -data class AudioPlaybackState( - val isPlaying: Boolean = false, - val isCompleted: Boolean = false, - val isPrepared: Boolean = false, - val currentPositionMs: Int = 0, - val durationMs: Int = 0, -) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt deleted file mode 100644 index c8a9173ee4f..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt +++ /dev/null @@ -1,25 +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 - -data class AudioPlayerNavArgs( - val localPath: String? = null, - val contentUrl: String? = null, - val fileName: String? = null, -) - diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt deleted file mode 100644 index 560a29e1aa8..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt +++ /dev/null @@ -1,422 +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.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.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.Brush -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.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 - -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 = AudioPlayerNavArgs::class, -) -@Composable -fun CellAudioPlayerScreen( - navigator: WireNavigator, - modifier: Modifier = Modifier, - viewModel: CellAudioPlayerViewModel = cellAudioPlayerViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - CellAudioPlayerContent( - state = state, - fileName = viewModel.fileName, - onTogglePlayPause = viewModel::togglePlayPause, - onSeek = viewModel::seekTo, - onStop = viewModel::pause, - onNavigateBack = navigator::navigateBack, - modifier = modifier, - ) -} - -@Suppress("LongMethod", "CyclomaticComplexMethod") -@Composable -internal fun CellAudioPlayerContent( - state: AudioPlaybackState, - fileName: String?, - onTogglePlayPause: () -> Unit, - onSeek: (Int) -> Unit, - onStop: () -> Unit, - onNavigateBack: () -> Unit, - modifier: Modifier = Modifier, -) { - var isSeeking by remember { mutableStateOf(false) } - var seekProgress by remember { mutableFloatStateOf(0f) } - - fun stopAndBack() { - onStop() - onNavigateBack() - } - - BackHandler { stopAndBack() } - - WireScaffold( - modifier = modifier, - topBar = { - WireCenterAlignedTopAppBar( - title = fileName ?: stringResource(R.string.conversation_files_title), - navigationIconType = NavigationIconType.Back(), - onNavigationPressed = ::stopAndBack, - ) - }, - ) { innerPadding -> - - 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)) - - 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, - 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 - } - - 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(), - ) - - 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, - ) - } - - Spacer(modifier = Modifier.height(dimensions().spacing24x)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) - } - ) { - 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)) - - 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(AccentColor) - .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.White, - 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 - val infiniteTransition = rememberInfiniteTransition(label = "equalizerBars") - - val heights = (0 until barCount).map { index -> - val durationMs = 400 + index * 80 - val initialValue = 0.15f + (index % 3) * 0.1f - val targetValue = if (isPlaying) 0.5f + (index % 4) * 0.15f else initialValue - - infiniteTransition.animateFloat( - initialValue = initialValue, - targetValue = targetValue, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = durationMs, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "bar$index", - ) - } - - val maxBarHeightPx = dimensions().spacing32x - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(maxBarHeightPx), - ) { - heights.forEach { heightState -> - val fraction by heightState - Box( - modifier = Modifier - .width(dimensions().spacing6x) - .height(maxBarHeightPx * fraction) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) - .background( - 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( - 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 deleted file mode 100644 index f3493b63455..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt +++ /dev/null @@ -1,135 +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 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 - - 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 - } -} 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 13334b52af3..8d6dcd7bfc7 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 @@ -37,11 +37,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.wire.android.feature.cells.ui.searchScreenViewModel 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.MoveToFolderScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination @@ -53,8 +51,6 @@ 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.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 @@ -62,6 +58,8 @@ import com.wire.android.feature.cells.ui.search.filter.bottomsheet.conversation. import com.wire.android.feature.cells.ui.search.filter.bottomsheet.owner.FilterByOwnerBottomSheet import com.wire.android.feature.cells.ui.search.filter.bottomsheet.tags.FilterByTagsBottomSheet import com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu +import com.wire.android.feature.cells.ui.searchScreenViewModel +import com.wire.android.feature.cells.ui.videoplayer.VideoViewerNavArgs import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.cells.WireCellsDestination @@ -272,7 +270,7 @@ fun SearchScreen( ) ) }, - showVideoViewer = { file -> + showVideoPlayer = { file -> navigator.navigate( NavigationCommand( VideoPlayerScreenDestination( @@ -285,19 +283,6 @@ fun SearchScreen( ) ) }, - showAudioPlayer = { file -> - navigator.navigate( - NavigationCommand( - CellAudioPlayerScreenDestination( - AudioPlayerNavArgs( - localPath = file.localPath, - contentUrl = file.contentUrl, - fileName = file.name, - ) - ) - ) - ) - }, retryEditNodeError = { cellViewModel.editNode(it) }, isRefreshing = remember { mutableStateOf(false) }, onRefresh = { }, From c92031c18049d80f838ff381052409b6de8c0186 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 12:07:44 +0100 Subject: [PATCH 09/11] feat: handle folder click --- .../feature/cells/ui/search/SearchScreen.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 8d6dcd7bfc7..df0be9e39aa 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 @@ -41,6 +41,8 @@ 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.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 @@ -60,6 +62,7 @@ import com.wire.android.feature.cells.ui.search.filter.bottomsheet.tags.FilterBy import com.wire.android.feature.cells.ui.search.sort.SortRowWithMenu import com.wire.android.feature.cells.ui.searchScreenViewModel 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.WireNavigator import com.wire.android.navigation.annotation.features.cells.WireCellsDestination @@ -209,7 +212,18 @@ fun SearchScreen( isSearchResult = true, isRestoreInProgress = cellViewModel.isRestoreInProgress.collectAsState().value, isDeleteInProgress = cellViewModel.isDeleteInProgress.collectAsState().value, - openFolder = { _, _, _ -> }, + openFolder = { path, title, parentFolderUuid -> + navigator.navigate( + NavigationCommand( + ConversationFilesWithSlideInTransitionScreenDestination( + conversationId = path, + screenTitle = title, + parentFolderUuid = parentFolderUuid, + ), + BackStackMode.NONE, + launchSingleTop = false + ) + ) }, showPublicLinkScreen = { publicLinkScreenData -> navigator.navigate( NavigationCommand( From cf69f879c2c0ef4bdeeef695722cb1df2b539743 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 14:18:01 +0100 Subject: [PATCH 10/11] feat: cleanup --- .../cells/ui/videoplayer/VideoPlayerScreen.kt | 13 ++++---- .../ui/videoplayer/VideoPlayerViewModel.kt | 2 +- .../main/res/drawable/ic_cell_skip_back.xml | 33 ------------------- .../res/drawable/ic_cell_skip_forward.xml | 33 ------------------- ..._cell_fullscreen.xml => ic_fullscreen.xml} | 0 ...screen_exit.xml => ic_fullscreen_exit.xml} | 0 .../{ic_cell_pause.xml => ic_pause.xml} | 0 .../{ic_cell_play.xml => ic_play.xml} | 0 .../{ic_cell_replay.xml => ic_replay.xml} | 0 ..._cell_volume_off.xml => ic_volume_off.xml} | 0 ...ic_cell_volume_on.xml => ic_volume_on.xml} | 0 kalium | 2 +- 12 files changed, 8 insertions(+), 75 deletions(-) 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 rename features/cells/src/main/res/drawable/{ic_cell_fullscreen.xml => ic_fullscreen.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_fullscreen_exit.xml => ic_fullscreen_exit.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_pause.xml => ic_pause.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_play.xml => ic_play.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_replay.xml => ic_replay.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_volume_off.xml => ic_volume_off.xml} (100%) rename features/cells/src/main/res/drawable/{ic_cell_volume_on.xml => ic_volume_on.xml} (100%) 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 a4315de74da..9590dc7c5e8 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 @@ -149,11 +149,10 @@ internal fun CellVideoViewerScreenContent( val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE - // Flag to track if the screen is immediately exiting var isExiting by remember { mutableStateOf(false) } // 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. + // it survives the activity recreation on rotation var controlsVisible by remember { mutableStateOf(true) } var isSeeking by remember { mutableStateOf(false) } var seekProgress by remember { mutableFloatStateOf(0f) } @@ -351,9 +350,9 @@ internal fun CellVideoViewerScreenContent( label = "videoButtonIcon", ) { 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 + VideoButtonState.PLAY -> R.drawable.ic_play + VideoButtonState.PAUSE -> R.drawable.ic_pause + VideoButtonState.REPLAY -> R.drawable.ic_replay } Icon( painter = painterResource(res), @@ -441,7 +440,7 @@ internal fun CellVideoViewerScreenContent( ) Icon( painter = painterResource( - if (state.isMuted) R.drawable.ic_cell_volume_off else R.drawable.ic_cell_volume_on + if (state.isMuted) R.drawable.ic_volume_off else R.drawable.ic_volume_on ), contentDescription = stringResource( if (state.isMuted) R.string.cells_video_unmute else R.string.cells_video_mute @@ -457,7 +456,7 @@ internal fun CellVideoViewerScreenContent( ) Icon( painter = painterResource( - if (isLandscape) R.drawable.ic_cell_fullscreen_exit else R.drawable.ic_cell_fullscreen + if (isLandscape) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen ), contentDescription = stringResource( if (isLandscape) { 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 a7eca996e4a..31e45c46985 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 @@ -49,7 +49,7 @@ class VideoPlayerViewModel( 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. + // without re-buffering the media. val player: ExoPlayer = ExoPlayer.Builder(context).build() private val _state = MutableStateFlow( 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 ea73bdc16ae..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 02e4f2ee56e..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_fullscreen.xml b/features/cells/src/main/res/drawable/ic_fullscreen.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_fullscreen.xml rename to features/cells/src/main/res/drawable/ic_fullscreen.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml b/features/cells/src/main/res/drawable/ic_fullscreen_exit.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_fullscreen_exit.xml rename to features/cells/src/main/res/drawable/ic_fullscreen_exit.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_pause.xml b/features/cells/src/main/res/drawable/ic_pause.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_pause.xml rename to features/cells/src/main/res/drawable/ic_pause.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_play.xml b/features/cells/src/main/res/drawable/ic_play.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_play.xml rename to features/cells/src/main/res/drawable/ic_play.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_replay.xml b/features/cells/src/main/res/drawable/ic_replay.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_replay.xml rename to features/cells/src/main/res/drawable/ic_replay.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_off.xml b/features/cells/src/main/res/drawable/ic_volume_off.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_volume_off.xml rename to features/cells/src/main/res/drawable/ic_volume_off.xml diff --git a/features/cells/src/main/res/drawable/ic_cell_volume_on.xml b/features/cells/src/main/res/drawable/ic_volume_on.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_cell_volume_on.xml rename to features/cells/src/main/res/drawable/ic_volume_on.xml diff --git a/kalium b/kalium index 5388ccef168..788cec68069 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 5388ccef16804dd39cc20a73fa6f9cd9665e040a +Subproject commit 788cec68069770486d610e92f628dd6941402846 From b8d972b883a969c2e99416c3627314aa885decd6 Mon Sep 17 00:00:00 2001 From: ohassine Date: Fri, 12 Jun 2026 16:32:13 +0100 Subject: [PATCH 11/11] feat: detekt --- .../usecase/GetConversationsFromSearchUseCase.kt | 13 +++++-------- .../feature/cells/ui/CellsMetroViewModelBindings.kt | 3 +++ .../android/feature/cells/ui/CellsViewModelGraph.kt | 3 +++ .../android/feature/cells/ui/search/SearchScreen.kt | 3 ++- .../cells/ui/videoplayer/VideoPlayerScreen.kt | 1 + .../cells/ui/videoplayer/VideoViewerNavArgs.kt | 1 - .../android/feature/cells/ui/CellViewModelTest.kt | 1 - kalium | 2 +- 8 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt index cd03e3b16cb..252afbb6c5b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationsFromSearchUseCase.kt @@ -18,13 +18,11 @@ package com.wire.android.ui.home.conversations.usecase -import android.os.SystemClock import androidx.paging.LoadState import androidx.paging.LoadStates import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map -import com.wire.android.appLogger import com.wire.android.mapper.UserTypeMapper import com.wire.android.mapper.toConversationItem import com.wire.android.ui.home.conversationslist.model.ConversationItem @@ -33,16 +31,15 @@ import com.wire.android.util.ui.UiTextResolver import com.wire.kalium.logic.data.conversation.ConversationDetailsWithEvents import com.wire.kalium.logic.data.conversation.ConversationFilter import com.wire.kalium.logic.data.conversation.ConversationQueryConfig -import com.wire.kalium.logic.data.id.TeamId import com.wire.kalium.logic.feature.conversation.GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase import com.wire.kalium.logic.feature.conversation.folder.GetFavoriteFolderUseCase import com.wire.kalium.logic.feature.conversation.folder.ObserveConversationsFromFolderUseCase import com.wire.kalium.logic.feature.user.GetSelfTeamIdUseCase +import dev.zacsweers.metro.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import dev.zacsweers.metro.Inject class GetConversationsFromSearchUseCase @Inject constructor( private val useCase: GetPaginatedFlowOfConversationDetailsWithEventsBySearchQueryUseCase, @@ -100,10 +97,10 @@ class GetConversationsFromSearchUseCase @Inject constructor( .map { staticPagingItems(it) } } }.map { pagingData -> - pagingData.map { - it.toConversationItem(userTypeMapper, uiTextResolver, selfUserTeamId) - } - }.flowOn(dispatchers.io()) + pagingData.map { + it.toConversationItem(userTypeMapper, uiTextResolver, selfUserTeamId) + } + }.flowOn(dispatchers.io()) } private fun staticPagingItems(conversations: List): PagingData { 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 4b375fbd558..cdfd8787e72 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 @@ -15,6 +15,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ + +@file:Suppress("TooManyFunctions") + package com.wire.android.feature.cells.ui import androidx.lifecycle.ViewModel 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 66b83287ea4..45d1125e180 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 @@ -15,6 +15,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ + +@file:Suppress("TooManyFunctions") + package com.wire.android.feature.cells.ui import androidx.compose.runtime.Composable 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 df0be9e39aa..14c9efa50c7 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 @@ -223,7 +223,8 @@ fun SearchScreen( BackStackMode.NONE, launchSingleTop = false ) - ) }, + ) + }, showPublicLinkScreen = { publicLinkScreenData -> navigator.navigate( NavigationCommand( 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 9590dc7c5e8..497ee37da95 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 @@ -490,6 +490,7 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { else -> null } +@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/videoplayer/VideoViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt index b26f79b72bf..80d4d77a984 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt @@ -22,4 +22,3 @@ data class VideoViewerNavArgs( val contentUrl: String? = null, val fileName: String? = null, ) - 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 350693325a9..ff0aae6d95d 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,7 +25,6 @@ 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 diff --git a/kalium b/kalium index 788cec68069..3a39fb17538 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 788cec68069770486d610e92f628dd6941402846 +Subproject commit 3a39fb17538cef4c96eee5459cf9707924889a24