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 ca5b1d1c4cc..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 @@ -97,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/build.gradle.kts b/features/cells/build.gradle.kts index 304f74108b6..3d4f11a996a 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -36,6 +36,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/AllFilesScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/AllFilesScreen.kt index 7cc400a5f97..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 @@ -31,10 +31,12 @@ import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTa import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination +import com.ramcosta.composedestinations.generated.cells.destinations.VideoPlayerScreenDestination import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.common.OfflineBanner import com.wire.android.feature.cells.ui.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 @@ -133,6 +135,19 @@ fun AllFilesScreen( ) ) }, + showVideoPlayer = { file -> + navigator.navigate( + NavigationCommand( + VideoPlayerScreenDestination( + VideoViewerNavArgs( + 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 7ff4c9d37da..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,6 +100,7 @@ internal fun CellScreenContent( retryEditNodeError: (String) -> Unit = {}, showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> }, showImageViewer: (CellNodeUi.File) -> Unit = {}, + showVideoPlayer: (CellNodeUi.File) -> Unit = {}, fileReadyFlow: Flow? = emptyFlow(), ) { @@ -257,6 +258,7 @@ internal fun CellScreenContent( ).show() } is OpenImageViewer -> showImageViewer(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 6516538dfdd..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 @@ -371,9 +371,18 @@ class CellViewModel( } 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(OpenVideoPlayer(file)) + return + } + + else -> Unit } file.contentUrl?.let { url -> fileHelper.openAssetUrlWithExternalApp( @@ -387,9 +396,18 @@ class CellViewModel( } 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(OpenVideoPlayer(file)) + return + } + + else -> Unit } file.localPath?.let { path -> fileHelper.openAssetFileWithExternalApp( @@ -638,6 +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 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 65d887db65b..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,13 +15,18 @@ * 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 +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.CreationExtras 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 +35,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.videoplayer.VideoPlayerViewModel import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.IntoMap import dev.zacsweers.metro.Provides @@ -105,6 +111,25 @@ 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(VideoPlayerViewModel::class) + fun videoViewerViewModel(factory: CellsViewModelFactory): ViewModelAssistedFactory = + savedStateViewModel { + factory.cellVideoViewerViewModel( + 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 dcc141d31d9..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 @@ -17,6 +17,7 @@ */ package com.wire.android.feature.cells.ui +import android.content.Context import androidx.lifecycle.SavedStateHandle import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel @@ -30,6 +31,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.videoplayer.VideoPlayerViewModel import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter import com.wire.android.util.dispatchers.DispatcherProvider @@ -214,4 +216,12 @@ class CellsViewModelFactory @Inject constructor( internal fun cellImageViewerViewModel(savedStateHandle: SavedStateHandle) = CellImageViewerViewModel( savedStateHandle = savedStateHandle, ) + + internal fun cellVideoViewerViewModel( + context: Context, + savedStateHandle: SavedStateHandle + ) = 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 5c49860903a..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 @@ -33,6 +36,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.videoplayer.VideoPlayerViewModel @Composable inline fun cellsViewModel( @@ -87,3 +91,6 @@ fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel() @Composable fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() + +@Composable +fun cellVideoViewerViewModel(): VideoPlayerViewModel = 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 315c6cffff8..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 @@ -55,15 +55,17 @@ 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.common.OfflineBanner -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 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.VideoViewerNavArgs import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.PreviewNavigator @@ -378,6 +380,19 @@ internal fun ConversationFilesScreenContent( ) ) }, + showVideoPlayer = { file -> + navigator.navigate( + NavigationCommand( + VideoPlayerScreenDestination( + VideoViewerNavArgs( + 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/search/SearchScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/search/SearchScreen.kt index a09358085ea..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 @@ -37,15 +37,17 @@ 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.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 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 @@ -58,6 +60,9 @@ 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.BackStackMode import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.WireNavigator import com.wire.android.navigation.annotation.features.cells.WireCellsDestination @@ -207,7 +212,19 @@ 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( @@ -268,6 +285,19 @@ fun SearchScreen( ) ) }, + showVideoPlayer = { file -> + navigator.navigate( + NavigationCommand( + VideoPlayerScreenDestination( + VideoViewerNavArgs( + 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/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/videoplayer/VideoPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt new file mode 100644 index 00000000000..497ee37da95 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt @@ -0,0 +1,518 @@ +/* + * 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.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import android.content.res.Configuration +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.PaddingValues +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.saveable.rememberSaveable +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.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 +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +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 +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 +import com.wire.android.ui.theme.WireTheme +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private const val CONTROLS_AUTO_HIDE_MS = 3_000L + +@WireCellsDestination( + style = PopUpNavigationAnimation::class, + navArgs = VideoViewerNavArgs::class, +) +@Composable +fun VideoPlayerScreen( + navigator: WireNavigator, + modifier: Modifier = Modifier, + 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, + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun CellVideoViewerScreenContent( + player: ExoPlayer, + state: VideoPlaybackState, + localPath: String?, + fileName: String?, + onTogglePlayPause: () -> Unit, + onToggleMute: () -> Unit, + onSeek: (Long) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val view = LocalView.current + val scope = rememberCoroutineScope() + + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + 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 + var controlsVisible by remember { mutableStateOf(true) } + var isSeeking by remember { mutableStateOf(false) } + var seekProgress by remember { mutableFloatStateOf(0f) } + + // 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() { + autoHideJob?.cancel() + autoHideJob = scope.launch { + delay(CONTROLS_AUTO_HIDE_MS) + controlsVisible = false + } + } + + fun showControls(autoHide: Boolean = state.isPlaying) { + controlsVisible = true + if (autoHide) scheduleAutoHide() + } + + fun toggleControls() { + if (controlsVisible) { + if (state.isPlaying) { + autoHideJob?.cancel() + controlsVisible = false + } + } else { + showControls() + } + } + + // auto-hide while playing, keep visible while paused or completed. + LaunchedEffect(state.isPlaying, state.isCompleted) { + when { + state.isCompleted -> { + autoHideJob?.cancel() + controlsVisible = true + } + + state.isPlaying -> scheduleAutoHide() + else -> { + autoHideJob?.cancel() + showControls(autoHide = false) + } + } + } + + // 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 + } + } + + LaunchedEffect(lockedOrientation) { + context.findActivity()?.requestedOrientation = lockedOrientation + } + + 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()) + } + } + + DisposableEffect(Unit) { + onDispose { + autoHideJob?.cancel() + } + } + + fun stopAndNavigateBack() { + isExiting = true + player.pause() + context.findActivity()?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER + onNavigateBack() + } + + BackHandler { + if (isLandscape) { + toggleFullScreen() + } else { + stopAndNavigateBack() + } + } + + WireScaffold( + modifier = modifier, + topBar = { + if (!isLandscape && !isExiting) { + WireCenterAlignedTopAppBar( + title = fileName ?: stringResource(R.string.conversation_files_title), + navigationIconType = NavigationIconType.Back(), + onNavigationPressed = ::stopAndNavigateBack, + ) + } + }, + ) { innerPadding -> + + Box( + modifier = Modifier + .padding(if (isLandscape) PaddingValues(0.dp) else innerPadding) + .fillMaxSize() + .background(Color.Black) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { if (!isExiting) toggleControls() }, + contentAlignment = Alignment.Center, + ) { + if (!isExiting) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + setPlayer(player) + useController = false + setBackgroundColor(android.graphics.Color.BLACK) + } + }, + modifier = Modifier.fillMaxSize(), + onRelease = { it.player = null }, + ) + } + + if (state.isBuffering && !isExiting) { + WireCircularProgressIndicator( + progressColor = Color.White, + size = dimensions().spacing48x, + modifier = Modifier.size(dimensions().spacing48x), + ) + } + + AnimatedVisibility( + visible = !state.isStarted && !isExiting, + 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(), + ) + } + } + + if (!isExiting && !state.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(dimensions().spacing72x) + .scale(buttonScale) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onTogglePlayPause() }, + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = when { + state.isCompleted -> VideoButtonState.REPLAY + state.isPlaying -> VideoButtonState.PAUSE + else -> VideoButtonState.PLAY + }, + transitionSpec = { + (scaleIn(animationSpec = spring(Spring.DampingRatioLowBouncy)) + fadeIn()) togetherWith + (scaleOut() + fadeOut()) + }, + label = "videoButtonIcon", + ) { buttonState -> + val res = when (buttonState) { + VideoButtonState.PLAY -> R.drawable.ic_play + VideoButtonState.PAUSE -> R.drawable.ic_pause + VideoButtonState.REPLAY -> R.drawable.ic_replay + } + Icon( + painter = painterResource(res), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } + } + } + + AnimatedVisibility( + visible = controlsVisible && !isExiting, + 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 (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).toLong()) + 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, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.currentPositionMs.toTimeString(), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing12x), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.durationMs.toTimeString(), + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + Icon( + painter = painterResource( + 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 + ), + tint = Color.White, + modifier = Modifier + .size(dimensions().spacing24x) + .clip(CircleShape) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onToggleMute() }, + ) + Icon( + painter = painterResource( + if (isLandscape) R.drawable.ic_fullscreen_exit else R.drawable.ic_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() }, + ) + } + } + } + } + } + } +} + +private enum class VideoButtonState { PLAY, PAUSE, REPLAY } + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +@Suppress("MagicNumber") +private fun Int.toTimeString(): String { + val totalSec = this / 1000 + val min = totalSec / 60 + val sec = totalSec % 60 + return "%d:%02d".format(min, sec) +} + +@MultipleThemePreviews +@Composable +fun PreviewCellVideoViewerScreen() { + val context = LocalContext.current + val player = remember { ExoPlayer.Builder(context).build() } + 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..31e45c46985 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt @@ -0,0 +1,167 @@ +/* + * 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.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: VideoViewerNavArgs = 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. + 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/videoplayer/VideoViewerNavArgs.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt new file mode 100644 index 00000000000..80d4d77a984 --- /dev/null +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoViewerNavArgs.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui.videoplayer + +data class VideoViewerNavArgs( + val localPath: String? = null, + val contentUrl: String? = null, + val fileName: String? = null, +) diff --git a/features/cells/src/main/res/drawable/ic_fullscreen.xml b/features/cells/src/main/res/drawable/ic_fullscreen.xml new file mode 100644 index 00000000000..a9346240281 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_fullscreen.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_fullscreen_exit.xml b/features/cells/src/main/res/drawable/ic_fullscreen_exit.xml new file mode 100644 index 00000000000..652375d0ddb --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_fullscreen_exit.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_pause.xml b/features/cells/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000000..bd19f84957b --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_play.xml b/features/cells/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000000..39d8a025608 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_play.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_replay.xml b/features/cells/src/main/res/drawable/ic_replay.xml new file mode 100644 index 00000000000..8dab22ea820 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_replay.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_volume_off.xml b/features/cells/src/main/res/drawable/ic_volume_off.xml new file mode 100644 index 00000000000..ab7e71e81c4 --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_volume_off.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/features/cells/src/main/res/drawable/ic_volume_on.xml b/features/cells/src/main/res/drawable/ic_volume_on.xml new file mode 100644 index 00000000000..0c6eb40e34c --- /dev/null +++ b/features/cells/src/main/res/drawable/ic_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 08fa037d45f..715b2d05859 100644 --- a/features/cells/src/main/res/values/strings.xml +++ b/features/cells/src/main/res/values/strings.xml @@ -259,4 +259,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 25522fc91ce..ff8d7b42e7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,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 @@ -259,6 +260,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" }