diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed18ef28cec..9003f9d39ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -205,6 +205,7 @@ dependencies { implementationWithCoverage(projects.core.uiCommon) implementationWithCoverage(projects.core.di) implementationWithCoverage(projects.core.media) + implementationWithCoverage(projects.core.mediaPlayer) implementationWithCoverage(projects.core.notification) implementationWithCoverage(projects.core.search) implementationWithCoverage(projects.features.cells) diff --git a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt index c8d55244afa..05d08f66211 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/WireApplicationGraph.kt @@ -47,6 +47,7 @@ import com.wire.android.di.accountScoped.TeamModule import com.wire.android.di.accountScoped.UserModule import com.wire.android.feature.cells.ui.CellsMetroViewModelBindings import com.wire.android.feature.meetings.ui.MeetingsMetroViewModelBindings +import com.wire.android.mediaplayer.MediaPlayerMetroViewModelBindings import com.wire.android.notification.broadcastreceivers.EndOngoingCallReceiver import com.wire.android.notification.broadcastreceivers.IncomingCallActionReceiver import com.wire.android.notification.broadcastreceivers.NomadLogoutReceiver @@ -107,6 +108,7 @@ import dev.zacsweers.metrox.viewmodel.ViewModelGraph MeetingsMetroViewModelBindings::class, CoreUICommonMetroViewModelBindings::class, SearchMetroViewModelBindings::class, + MediaPlayerMetroViewModelBindings::class, ] ) @Suppress("TooManyFunctions") diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 2a835d6ae20..22f6f534ccb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -95,6 +95,7 @@ import com.ramcosta.composedestinations.generated.app.destinations.MessageDetail import com.ramcosta.composedestinations.generated.app.destinations.OtherUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.SelfUserProfileScreenDestination import com.ramcosta.composedestinations.generated.app.destinations.ServiceDetailsScreenDestination +import com.ramcosta.composedestinations.generated.app.destinations.VideoPlayerScreenDestination import com.ramcosta.composedestinations.generated.sketch.destinations.DrawingCanvasScreenDestination import com.ramcosta.composedestinations.result.NavResult.Canceled import com.ramcosta.composedestinations.result.NavResult.Value @@ -512,6 +513,17 @@ fun ConversationScreen( updateImageOnFullscreenMode(message) } }, + onVideoClick = { localPath, contentUrl, fileName -> + navigator.navigate( + NavigationCommand( + VideoPlayerScreenDestination( + localPath = localPath, + contentUrl = contentUrl, + fileName = fileName, + ) + ) + ) + }, onStartCall = { conversationCallViewModel.startCallIfPossible(conversationInfoViewModel.conversationInfoViewState.conversationType) }, @@ -827,6 +839,7 @@ private fun ConversationScreen( onDeleteMessage: (String, Boolean) -> Unit, onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, + onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onStartCall: () -> Unit, onJoinCall: () -> Unit, onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, @@ -937,6 +950,7 @@ private fun ConversationScreen( onAudioRecorded = onAudioRecorded, onAssetItemClicked = onAssetItemClicked, onImageFullScreenMode = onImageFullScreenMode, + onVideoClick = onVideoClick, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, onOpenProfile = onOpenProfile, @@ -1025,6 +1039,7 @@ private fun ConversationScreenContent( onAudioRecorded: (UriAsset) -> Unit, onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, + onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, @@ -1081,6 +1096,7 @@ private fun ConversationScreenContent( onReactionClicked = onReactionClicked, onAssetClicked = onAssetItemClicked, onImageClicked = onImageFullScreenMode, + onVideoClicked = onVideoClick, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, onResetSessionClicked = onResetSessionClicked, @@ -1837,6 +1853,7 @@ fun PreviewConversationScreen() = WireTheme { onDeleteMessage = { _, _ -> }, onAssetItemClicked = { }, onImageFullScreenMode = { _, _, _ -> }, + onVideoClick = { _, _, _ -> }, onStartCall = { }, onJoinCall = { }, onReactionClick = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index cdd8dfd73db..62274c1d983 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -29,6 +29,7 @@ sealed class MessageClickActions { open val onReactionClicked: (String, String) -> Unit = { _, _ -> } open val onAssetClicked: (String) -> Unit = {} open val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> } + open val onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit = { _, _, _ -> } open val onLinkClicked: (String) -> Unit = {} open val onReplyClicked: (UIMessage.Regular) -> Unit = {} open val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> } @@ -46,6 +47,7 @@ sealed class MessageClickActions { override val onReactionClicked: (String, String) -> Unit = { _, _ -> }, override val onAssetClicked: (String) -> Unit = {}, override val onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit = { _, _, _ -> }, + override val onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit = { _, _, _ -> }, override val onLinkClicked: (String) -> Unit = {}, override val onReplyClicked: (UIMessage.Regular) -> Unit = {}, override val onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit = { _, _ -> }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index 8b0307ece1e..e84307f9f7a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -72,6 +72,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( messageStyle: MessageStyle, onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit, + onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onProfileClicked: (senderId: MessageSenderId) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -119,6 +120,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onAssetClick = onAssetClickable, onImageClick = onImageClickable, onMultipartImageClick = onMultipartImageClickable, + onMultipartVideoClick = onVideoClicked, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -167,6 +169,7 @@ private fun MessageContent( onAssetClick: Clickable, onImageClick: Clickable, onMultipartImageClick: (String) -> Unit, + onMultipartVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -451,7 +454,8 @@ private fun MessageContent( conversationId = message.conversationId, attachments = messageContent.attachments, messageStyle = messageStyle, - onImageAttachmentClick = onMultipartImageClick + onImageAttachmentClick = onMultipartImageClick, + onVideoAttachmentClick = onMultipartVideoClick, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt index 94b50ad3c26..59184ca075e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentItem.kt @@ -95,6 +95,7 @@ fun MessageContentItem( messageStyle = messageStyle, onAssetClicked = clickActions.onAssetClicked, onImageClicked = clickActions.onImageClicked, + onVideoClicked = clickActions.onVideoClicked, searchQuery = searchQuery, accent = accent, onProfileClicked = clickActions.onProfileClicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt index 1061f45a6a1..d9581475c57 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsView.kt @@ -58,6 +58,7 @@ fun MultipartAttachmentsView( attachments: List, messageStyle: MessageStyle, onImageAttachmentClick: (String) -> Unit, + onVideoAttachmentClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview @@ -89,6 +90,9 @@ fun MultipartAttachmentsView( viewModel.onClick( attachment = it, openInImageViewer = onImageAttachmentClick, + openInVideoPlayer = { att -> + onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) + }, ) }, ) @@ -119,6 +123,9 @@ fun MultipartAttachmentsView( viewModel.onClick( attachment = it, openInImageViewer = onImageAttachmentClick, + openInVideoPlayer = { att -> + onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) + }, ) }, ) @@ -131,6 +138,9 @@ fun MultipartAttachmentsView( viewModel.onClick( attachment = it, openInImageViewer = onImageAttachmentClick, + openInVideoPlayer = { att -> + onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) + }, ) }, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt index 3cfadb3a980..972afb2902e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModel.kt @@ -53,7 +53,11 @@ import okio.Path.Companion.toPath interface MultipartAttachmentsViewModel { val offlineAttachmentIds: StateFlow> - fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) + fun onClick( + attachment: MultipartAttachmentUi, + openInImageViewer: (String) -> Unit, + openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + ) fun mapAttachment(attachment: MessageAttachment): MultipartAttachmentUi { val isAvailableOffline = attachment.assetId() in offlineAttachmentIds.value return attachment.toUiModel(isAvailableOffline = isAvailableOffline) @@ -115,7 +119,11 @@ interface MultipartAttachmentsViewModel { @Suppress("EmptyFunctionBlock") object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { override val offlineAttachmentIds: StateFlow> = MutableStateFlow(emptySet()) - override fun onClick(attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit) {} + override fun onClick( + attachment: MultipartAttachmentUi, + openInImageViewer: (String) -> Unit, + openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + ) {} override fun onAttachmentsVisible(attachments: List) {} override fun onAttachmentsHidden(attachments: List) {} } @@ -148,6 +156,7 @@ class MultipartAttachmentsViewModelImpl( override fun onClick( attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, + openInVideoPlayer: (MultipartAttachmentUi) -> Unit, ) { when { attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) @@ -158,6 +167,9 @@ class MultipartAttachmentsViewModelImpl( refreshHelper.refresh(attachment.uuid) } + attachment.isVideo() && (attachment.localFileAvailable() || attachment.canOpenWithUrl()) -> + openInVideoPlayer(attachment) + attachment.localFileAvailable() -> openLocalFile(attachment) attachment.canOpenWithUrl() -> openUrl(attachment) else -> downloadAsset(attachment) @@ -250,6 +262,8 @@ private fun MessageAttachment.mimeType() = private fun MultipartAttachmentUi.isImage() = AttachmentFileType.fromMimeType(mimeType) == IMAGE +private fun MultipartAttachmentUi.isVideo() = assetType == VIDEO + private fun MessageAttachment.isMediaAttachment() = when (AttachmentFileType.fromMimeType(mimeType())) { IMAGE, VIDEO -> true diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.kt new file mode 100644 index 00000000000..6691593b773 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerNavArgs.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.ui.home.conversations.videoplayer + +data class VideoPlayerNavArgs( + val localPath: String? = null, + val contentUrl: String? = null, + val fileName: String? = null, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt new file mode 100644 index 00000000000..f23756ff663 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/videoplayer/VideoPlayerScreen.kt @@ -0,0 +1,45 @@ +/* + * 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.ui.home.conversations.videoplayer + +import androidx.compose.runtime.Composable +import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.annotation.app.WireRootDestination +import com.wire.android.navigation.style.PopUpNavigationAnimation + +/** + * App navigation entry point for the shared [VideoPlayer]. Lets chat (and any app screen) play a + * downloaded video in-app instead of handing it off to an external application. + */ +@WireRootDestination( + navArgs = VideoPlayerNavArgs::class, + style = PopUpNavigationAnimation::class, +) +@Composable +fun VideoPlayerScreen( + navigator: Navigator, + navArgs: VideoPlayerNavArgs, +) { + VideoPlayer( + localPath = navArgs.localPath, + contentUrl = navArgs.contentUrl, + fileName = navArgs.fileName, + onNavigateBack = navigator::navigateBack, + ) +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt index 58cb093250a..b26d87e3df3 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/multipart/MultipartAttachmentsViewModelTest.kt @@ -186,7 +186,7 @@ class MultipartAttachmentsViewModelTest { val callback = mockk(relaxed = true) - viewModel.onClick(testAttachmentUi, callback) + viewModel.onClick(testAttachmentUi, callback, {}) coVerify(exactly = 1) { callback.invoke(testAttachmentUi.uuid) } } @@ -202,7 +202,8 @@ class MultipartAttachmentsViewModelTest { attachment = testAttachmentUi.copy( transferStatus = AssetTransferStatus.NOT_FOUND, ), - openInImageViewer = callback + openInImageViewer = callback, + openInVideoPlayer = { } ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -221,7 +222,8 @@ class MultipartAttachmentsViewModelTest { mimeType = "application/pdf", transferStatus = AssetTransferStatus.NOT_FOUND, ), - openInImageViewer = callback + openInImageViewer = callback, + openInVideoPlayer = { } ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -240,7 +242,8 @@ class MultipartAttachmentsViewModelTest { mimeType = "application/pdf", localPath = "local/path", ), - openInImageViewer = callback + openInImageViewer = callback, + openInVideoPlayer = { } ) coVerify(exactly = 1) { arrangement.fileManager.openWithExternalApp(any(), any(), any(), any()) } @@ -258,7 +261,8 @@ class MultipartAttachmentsViewModelTest { mimeType = "application/pdf", contentUrl = "content/url", ), - openInImageViewer = callback + openInImageViewer = callback, + openInVideoPlayer = { } ) coVerify(exactly = 1) { arrangement.fileManager.openUrlWithExternalApp(any(), any(), any()) } diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index fa67b76bb03..38306685fb8 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -541,6 +541,12 @@ public fun com.ramcosta.composedestinations.generated.app.destinations.VerifyEma restartable: true params: +@Composable +public fun com.ramcosta.composedestinations.generated.app.destinations.VideoPlayerScreenDestination.Content(): kotlin.Unit + skippable: true + restartable: true + params: + @Composable public fun com.ramcosta.composedestinations.generated.app.destinations.WelcomeChooserScreenDestination.Content(): kotlin.Unit skippable: true @@ -4531,7 +4537,7 @@ public fun com.wire.android.ui.home.conversations.ConversationScreen(navigator: - messageAttachmentsViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAsset: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMessage: com.wire.android.util.ui.UIText?, messageComposerViewState: com.wire.android.ui.home.conversations.MessageComposerViewState, conversationCallViewState: com.wire.android.ui.home.conversations.call.ConversationCallViewState, conversationInfoViewState: com.wire.android.ui.home.conversations.info.ConversationInfoViewState, conversationMessagesViewState: com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState, attachments: kotlin.collections.List, bottomSheetVisible: kotlin.Boolean, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onMessageDetailsClick: kotlin.Function2<@[ParameterName(name = \, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onDeleteMessage: kotlin.Function2, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onStartCall: kotlin.Function0, onJoinCall: kotlin.Function0, onReactionClick: kotlin.Function2<@[ParameterName(name = \, onResetSessionClick: kotlin.Function2<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onDropDownClick: kotlin.Function0, onBackButtonClick: kotlin.Function0, composerMessages: kotlinx.coroutines.flow.SharedFlow, conversationMessages: kotlinx.coroutines.flow.SharedFlow, shareAsset: kotlin.Function2, onSelfDeletingMessageRead: kotlin.Function1, onNewSelfDeletingMessagesStatus: kotlin.Function1, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onFailedMessageRetryClicked: kotlin.Function2, onClearMentionSearchResult: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, conversationScreenState: com.wire.android.ui.home.conversations.ConversationScreenState, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, onLinkClick: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -4552,6 +4558,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMess - onDeleteMessage: STABLE (function type) - onAssetItemClicked: STABLE (function type) - onImageFullScreenMode: STABLE (function type) + - onVideoClick: STABLE (function type) - onStartCall: STABLE (function type) - onJoinCall: STABLE (function type) - onReactionClick: STABLE (function type) @@ -4585,7 +4592,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreen(bannerMess - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreenContent(conversationId: com.wire.kalium.logic.data.id.QualifiedID, bottomSheetVisible: kotlin.Boolean, lastUnreadMessageInstant: kotlinx.datetime.Instant?, unreadEventCount: kotlin.Int, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, selectedMessageId: kotlin.String?, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, attachments: kotlin.collections.List, messages: kotlinx.coroutines.flow.Flow>, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onReactionClicked: kotlin.Function2, onResetSessionClicked: kotlin.Function2<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onShowEditingOptions: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, onFailedMessageRetryClicked: kotlin.Function2, onFailedMessageCancelClicked: kotlin.Function1, onChangeSelfDeletionClicked: kotlin.Function1, onClearMentionSearchResult: kotlin.Function0, onLocationClicked: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onLinkClick: kotlin.Function1, onNavigateToReplyOriginalMessage: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit +private fun com.wire.android.ui.home.conversations.ConversationScreenContent(conversationId: com.wire.kalium.logic.data.id.QualifiedID, bottomSheetVisible: kotlin.Boolean, lastUnreadMessageInstant: kotlinx.datetime.Instant?, unreadEventCount: kotlin.Int, playingAudioMessage: com.wire.android.media.audiomessage.PlayingAudioMessage, assetStatuses: kotlinx.collections.immutable.PersistentMap, selectedMessageId: kotlin.String?, messageComposerStateHolder: com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder, attachments: kotlin.collections.List, messages: kotlinx.coroutines.flow.Flow>, onSendMessage: kotlin.Function1, onPingOptionClicked: kotlin.Function0, onImagesPicked: kotlin.Function2, kotlin.Boolean, kotlin.Unit>, onAttachmentPicked: kotlin.Function1, onAudioRecorded: kotlin.Function1, onAssetItemClicked: kotlin.Function1, onImageFullScreenMode: kotlin.Function3, onVideoClick: kotlin.Function3<@[ParameterName(name = \, onReactionClicked: kotlin.Function2, onResetSessionClicked: kotlin.Function2<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onUpdateConversationReadDate: kotlin.Function1, onShowEditingOptions: kotlin.Function1, onSwipedToReply: kotlin.Function1, onSelfDeletingMessageRead: kotlin.Function1, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, onFailedMessageRetryClicked: kotlin.Function2, onFailedMessageCancelClicked: kotlin.Function1, onChangeSelfDeletionClicked: kotlin.Function1, onClearMentionSearchResult: kotlin.Function0, onLocationClicked: kotlin.Function0, onPermissionPermanentlyDenied: kotlin.Function1<@[ParameterName(name = \, tempWritableImageUri: android.net.Uri?, tempWritableVideoUri: android.net.Uri?, onLinkClick: kotlin.Function1, onNavigateToReplyOriginalMessage: kotlin.Function1, openDrawingCanvas: kotlin.Function0, onAttachmentClick: kotlin.Function1, onAttachmentMenuClick: kotlin.Function1, currentTimeInMillisFlow: kotlinx.coroutines.flow.Flow, onReachedOldestMessage: kotlin.Function0, showHistoryLoadingIndicator: kotlin.Boolean, isFetchingOlderMessages: kotlin.Boolean, hasMoreRemoteMessages: kotlin.Boolean, isBubbleUiEnabled: kotlin.Boolean, isWireCellsEnabled: kotlin.Boolean): kotlin.Unit skippable: false restartable: true params: @@ -4606,6 +4613,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreenContent(con - onAudioRecorded: STABLE (function type) - onAssetItemClicked: STABLE (function type) - onImageFullScreenMode: STABLE (function type) + - onVideoClick: STABLE (function type) - onReactionClicked: STABLE (function type) - onResetSessionClicked: STABLE (function type) - onOpenProfile: STABLE (function type) @@ -6404,7 +6412,7 @@ public fun com.wire.android.ui.home.conversations.messages.item.MessageContainer - isWireCellsEnabled: STABLE (primitive type) @Composable -private fun com.wire.android.ui.home.conversations.messages.item.MessageContent(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, messageContent: com.wire.android.ui.home.conversations.model.UIMessageContent.Regular?, searchQuery: kotlin.String, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, assetStatus: com.wire.kalium.logic.data.asset.AssetTransferStatus?, onAssetClick: com.wire.android.model.Clickable, onImageClick: com.wire.android.model.Clickable, onMultipartImageClick: kotlin.Function1, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onLinkClick: kotlin.Function1, onReplyClick: com.wire.android.model.Clickable, accent: com.wire.android.ui.theme.Accent, conversationAssetPathsViewModel: com.wire.android.ui.home.conversations.messages.item.ConversationAssetPathsViewModel): kotlin.Unit +private fun com.wire.android.ui.home.conversations.messages.item.MessageContent(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, messageContent: com.wire.android.ui.home.conversations.model.UIMessageContent.Regular?, searchQuery: kotlin.String, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, assetStatus: com.wire.kalium.logic.data.asset.AssetTransferStatus?, onAssetClick: com.wire.android.model.Clickable, onImageClick: com.wire.android.model.Clickable, onMultipartImageClick: kotlin.Function1, onMultipartVideoClick: kotlin.Function3<@[ParameterName(name = \, onOpenProfile: kotlin.Function1<@[ParameterName(name = \, onLinkClick: kotlin.Function1, onReplyClick: com.wire.android.model.Clickable, accent: com.wire.android.ui.theme.Accent, conversationAssetPathsViewModel: com.wire.android.ui.home.conversations.messages.item.ConversationAssetPathsViewModel): kotlin.Unit skippable: false restartable: true params: @@ -6416,6 +6424,7 @@ private fun com.wire.android.ui.home.conversations.messages.item.MessageContent( - onAssetClick: STABLE (class with no mutable properties) - onImageClick: STABLE (class with no mutable properties) - onMultipartImageClick: STABLE (function type) + - onMultipartVideoClick: STABLE (function type) - onOpenProfile: STABLE (function type) - onLinkClick: STABLE (function type) - onReplyClick: STABLE (class with no mutable properties) @@ -6423,7 +6432,7 @@ private fun com.wire.android.ui.home.conversations.messages.item.MessageContent( - conversationAssetPathsViewModel: RUNTIME (requires runtime check) @Composable -internal fun com.wire.android.ui.home.conversations.messages.item.MessageContentAndStatus(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, assetStatus: com.wire.kalium.logic.data.asset.AssetTransferStatus?, searchQuery: kotlin.String, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, onAssetClicked: kotlin.Function1, onImageClicked: kotlin.Function3, onProfileClicked: kotlin.Function1<@[ParameterName(name = \, onLinkClicked: kotlin.Function1, onReplyClicked: kotlin.Function1, shouldDisplayMessageStatus: kotlin.Boolean, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, accent: com.wire.android.ui.theme.Accent): kotlin.Unit +internal fun com.wire.android.ui.home.conversations.messages.item.MessageContentAndStatus(message: com.wire.android.ui.home.conversations.model.UIMessage.Regular, assetStatus: com.wire.kalium.logic.data.asset.AssetTransferStatus?, searchQuery: kotlin.String, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, onAssetClicked: kotlin.Function1, onImageClicked: kotlin.Function3, onVideoClicked: kotlin.Function3<@[ParameterName(name = \, onProfileClicked: kotlin.Function1<@[ParameterName(name = \, onLinkClicked: kotlin.Function1, onReplyClicked: kotlin.Function1, shouldDisplayMessageStatus: kotlin.Boolean, conversationDetailsData: com.wire.android.ui.home.conversations.info.ConversationDetailsData, accent: com.wire.android.ui.theme.Accent): kotlin.Unit skippable: false restartable: true params: @@ -6433,6 +6442,7 @@ internal fun com.wire.android.ui.home.conversations.messages.item.MessageContent - messageStyle: STABLE (class with no mutable properties) - onAssetClicked: STABLE (function type) - onImageClicked: STABLE (function type) + - onVideoClicked: STABLE (function type) - onProfileClicked: STABLE (function type) - onLinkClicked: STABLE (function type) - onReplyClicked: STABLE (function type) @@ -7170,7 +7180,7 @@ private fun com.wire.android.ui.home.conversations.model.messagetypes.multipart. - modifier: STABLE (marked @Stable or @Immutable) @Composable -public fun com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsView(conversationId: com.wire.kalium.logic.data.id.QualifiedID, attachments: kotlin.collections.List, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, onImageAttachmentClick: kotlin.Function1, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsViewModel): kotlin.Unit +public fun com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsView(conversationId: com.wire.kalium.logic.data.id.QualifiedID, attachments: kotlin.collections.List, messageStyle: com.wire.android.ui.home.conversations.messages.item.MessageStyle, onImageAttachmentClick: kotlin.Function1, onVideoAttachmentClick: kotlin.Function3<@[ParameterName(name = \, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.home.conversations.model.messagetypes.multipart.MultipartAttachmentsViewModel): kotlin.Unit skippable: false restartable: true params: @@ -7178,6 +7188,7 @@ public fun com.wire.android.ui.home.conversations.model.messagetypes.multipart.M - attachments: RUNTIME (requires runtime check) - messageStyle: STABLE (class with no mutable properties) - onImageAttachmentClick: STABLE (function type) + - onVideoAttachmentClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: RUNTIME (requires runtime check) @@ -7581,6 +7592,14 @@ public fun com.wire.android.ui.home.conversations.updateChannelAccessViewModel() restartable: true params: +@Composable +public fun com.wire.android.ui.home.conversations.videoplayer.VideoPlayerScreen(navigator: com.wire.android.navigation.Navigator, navArgs: com.wire.android.ui.home.conversations.videoplayer.VideoPlayerNavArgs): kotlin.Unit + skippable: true + restartable: true + params: + - navigator: STABLE (marked @Stable or @Immutable) + - navArgs: STABLE (class with no mutable properties) + @Composable public fun com.wire.android.ui.home.conversationslist.ConversationsScreenContent(navigator: com.wire.android.navigation.Navigator, searchBarState: com.wire.android.ui.common.search.SearchBarState, modifier: androidx.compose.ui.Modifier, emptyListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction1<@[ParameterName(name = \, lazyListState: androidx.compose.foundation.lazy.LazyListState, loadingListContent: @[Composable] androidx.compose.runtime.internal.ComposableFunction0, conversationsSource: com.wire.android.ui.home.conversationslist.model.ConversationsSource, emptySearchResultFocusRequester: androidx.compose.ui.focus.FocusRequester?, firstConversationFocusRequester: androidx.compose.ui.focus.FocusRequester?, onConversationOpened: kotlin.Function0, conversationListCallViewModel: com.wire.android.ui.home.conversationslist.ConversationListCallViewModel, conversationListViewModel: com.wire.android.ui.home.conversationslist.ConversationListViewModel): kotlin.Unit skippable: false diff --git a/core/media-player/build.gradle.kts b/core/media-player/build.gradle.kts new file mode 100644 index 00000000000..03db8c22f40 --- /dev/null +++ b/core/media-player/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id(libs.plugins.wire.android.library.get().pluginId) + id(libs.plugins.wire.kover.get().pluginId) + id(BuildPlugins.junit5) + id(libs.plugins.wire.compose.compiler.get().pluginId) + alias(libs.plugins.compose.stability.analyzer) +} + +android { + namespace = "com.wire.android.mediaplayer" +} + +dependencies { + + implementation(project(":core:di")) + implementation(project(":core:ui-common")) + + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.coroutines.android) + + val composeBom = enforcedPlatform(libs.compose.bom) + implementation(composeBom) + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.androidx.lifecycle.viewModelCompose) + implementation(libs.compose.ui.preview) + implementation(libs.metrox.viewModelCompose) + debugImplementation(libs.compose.ui.tooling) + + implementation(libs.coil.core) + implementation(libs.coil.video) + implementation(libs.coil.compose) + + implementation(libs.media3.exoplayer) + implementation(libs.media3.ui) + + testImplementation(libs.junit5.core) + testImplementation(libs.coroutines.test) + testImplementation(libs.mockk.core) + testImplementation(libs.turbine) + testRuntimeOnly(libs.junit5.engine) + testImplementation(testFixtures(project(":core:ui-common"))) +} \ No newline at end of file diff --git a/core/media-player/lint-baseline.xml b/core/media-player/lint-baseline.xml new file mode 100644 index 00000000000..05a9be7dd95 --- /dev/null +++ b/core/media-player/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerMetroViewModelBindings.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerMetroViewModelBindings.kt new file mode 100644 index 00000000000..5a261f2b31e --- /dev/null +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerMetroViewModelBindings.kt @@ -0,0 +1,40 @@ +/* + * 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.mediaplayer + +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.IntoMap +import dev.zacsweers.metro.Provides +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactoryKey + +@BindingContainer +object MediaPlayerMetroViewModelBindings { + + @Provides + @IntoMap + @ManualViewModelAssistedFactoryKey(MediaPlayerManualViewModelFactory::class) + fun mediaPlayerManualViewModelFactory(factory: MediaPlayerViewModelFactory): ManualViewModelAssistedFactory = + object : MediaPlayerManualViewModelFactory { + override fun videoPlayerViewModel( + localPath: String?, + contentUrl: String?, + fileName: String?, + ): VideoPlayerViewModel = factory.videoPlayerViewModel(localPath, contentUrl, fileName) + } +} diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelFactory.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelFactory.kt new file mode 100644 index 00000000000..748adedd1a1 --- /dev/null +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelFactory.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.mediaplayer + +import android.content.Context +import com.wire.android.di.ApplicationContext +import dev.zacsweers.metro.Inject + +class MediaPlayerViewModelFactory @Inject constructor( + @ApplicationContext private val context: Context, +) { + fun videoPlayerViewModel( + localPath: String?, + contentUrl: String?, + fileName: String?, + ) = VideoPlayerViewModel( + context = context, + localPath = localPath, + contentUrl = contentUrl, + fileName = fileName, + ) +} diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt new file mode 100644 index 00000000000..7f4bc12bfc6 --- /dev/null +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt @@ -0,0 +1,44 @@ +/* + * 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/. + */ +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.mediaplayer + +import androidx.compose.runtime.Composable +import com.wire.android.di.metro.sessionKeyedAssistedMetroViewModel +import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory + +interface MediaPlayerManualViewModelFactory : ManualViewModelAssistedFactory { + fun videoPlayerViewModel( + localPath: String?, + contentUrl: String?, + fileName: String?, + ): VideoPlayerViewModel +} + +@Composable +fun videoPlayerViewModel( + localPath: String?, + contentUrl: String?, + fileName: String?, +): VideoPlayerViewModel = + sessionKeyedAssistedMetroViewModel( + key = "video_player_${localPath ?: contentUrl}" + ) { + videoPlayerViewModel(localPath, contentUrl, fileName) + } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.kt similarity index 94% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt rename to core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.kt index 3f8525614ae..8419a2b5931 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlaybackState.kt +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.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.videoplayer +package com.wire.android.mediaplayer data class VideoPlaybackState( val isPlaying: Boolean = false, diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt new file mode 100644 index 00000000000..5505390d1cc --- /dev/null +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt @@ -0,0 +1,517 @@ +/* + * 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.mediaplayer + +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.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 + +/** + * Reusable full-screen video player. Plays either a local file ([localPath]) or a remote + * [contentUrl]. Callers own navigation via [onNavigateBack]; the ViewModel is resolved from the + * shared media-player Metro graph so any module can host this screen. + */ +@Composable +fun VideoPlayer( + localPath: String?, + contentUrl: String?, + fileName: String?, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: VideoPlayerViewModel = videoPlayerViewModel(localPath, contentUrl, fileName), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + VideoPlayerContent( + player = viewModel.player, + state = state, + localPath = viewModel.localPath, + fileName = viewModel.fileName, + onTogglePlayPause = viewModel::togglePlayPause, + onToggleMute = viewModel::toggleMute, + onSeek = viewModel::seekTo, + onNavigateBack = onNavigateBack, + modifier = modifier, + ) +} + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +internal fun VideoPlayerContent( + 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.media_player_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.media_player_unmute else R.string.media_player_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.media_player_exit_fullscreen + } else { + R.string.media_player_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 { + VideoPlayerContent( + 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/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt similarity index 89% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt rename to core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt index 31e45c46985..6d1d55e9892 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerViewModel.kt +++ b/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt @@ -15,18 +15,15 @@ * 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 +package com.wire.android.mediaplayer 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 @@ -37,17 +34,19 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File +/** + * Plays a single video from either a local file ([localPath]) or a remote URL ([contentUrl]). + * + * The screen arguments are passed in through assisted injection (see [MediaPlayerViewModelFactory]) + * rather than read from a navigation destination, so the player can be reused from any module. + */ class VideoPlayerViewModel( - @ApplicationContext context: Context, - savedStateHandle: SavedStateHandle, + context: Context, + val localPath: String?, + val contentUrl: String?, + val fileName: String?, ) : 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() diff --git a/features/cells/src/main/res/drawable/ic_fullscreen.xml b/core/media-player/src/main/res/drawable/ic_fullscreen.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_fullscreen.xml rename to core/media-player/src/main/res/drawable/ic_fullscreen.xml diff --git a/features/cells/src/main/res/drawable/ic_fullscreen_exit.xml b/core/media-player/src/main/res/drawable/ic_fullscreen_exit.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_fullscreen_exit.xml rename to core/media-player/src/main/res/drawable/ic_fullscreen_exit.xml diff --git a/core/media-player/src/main/res/drawable/ic_pause.xml b/core/media-player/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000000..bd19f84957b --- /dev/null +++ b/core/media-player/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/core/media-player/src/main/res/drawable/ic_play.xml b/core/media-player/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000000..39d8a025608 --- /dev/null +++ b/core/media-player/src/main/res/drawable/ic_play.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/features/cells/src/main/res/drawable/ic_replay.xml b/core/media-player/src/main/res/drawable/ic_replay.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_replay.xml rename to core/media-player/src/main/res/drawable/ic_replay.xml diff --git a/features/cells/src/main/res/drawable/ic_volume_off.xml b/core/media-player/src/main/res/drawable/ic_volume_off.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_volume_off.xml rename to core/media-player/src/main/res/drawable/ic_volume_off.xml diff --git a/features/cells/src/main/res/drawable/ic_volume_on.xml b/core/media-player/src/main/res/drawable/ic_volume_on.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_volume_on.xml rename to core/media-player/src/main/res/drawable/ic_volume_on.xml diff --git a/core/media-player/src/main/res/values/strings.xml b/core/media-player/src/main/res/values/strings.xml new file mode 100644 index 00000000000..691dd180899 --- /dev/null +++ b/core/media-player/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + Video + Enter full screen + Exit full screen + Mute + Unmute + \ No newline at end of file diff --git a/crowdin.yml b/crowdin.yml index 8ef94749a98..60b5730b396 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -11,6 +11,10 @@ files: [ "source": "/app/src/main/res/values/strings.xml", "translation": "/app/src/main/res/values-%two_letters_code%/%original_file_name%" }, + { + "source": "/core/media-player/src/main/res/values/strings.xml", + "translation": "/core/media-player/src/main/res/values-%two_letters_code%/%original_file_name%" + }, { "source": "/core/search/src/main/res/values/strings.xml", "translation": "/core/search/src/main/res/values-%two_letters_code%/%original_file_name%" diff --git a/docs/adr/0013-shared-video-player-core-module.md b/docs/adr/0013-shared-video-player-core-module.md new file mode 100644 index 00000000000..0806d071e88 --- /dev/null +++ b/docs/adr/0013-shared-video-player-core-module.md @@ -0,0 +1,80 @@ +# 13. Shared in-app video player as a core module + +Date: 2026-07-16 + +## Status + +Proposed + +## Context + +The in-app video player (`VideoPlayerScreen`, `VideoPlayerViewModel`, `VideoPlaybackState`, +`VideoViewerNavArgs`) currently lives in `features:cells` and is reachable only from the cells file +browser. It is a self-contained, reusable UI component: it takes a local path / content URL and plays +it via ExoPlayer — it holds no domain logic of its own (assets, download and offline handling already +live in Kalium and in the calling features). + +We now want to play videos **in-app when a user taps a video message in a chat**. Today the chat +(which lives in the `app` module, `ui/home/conversations/…`) hands videos off to an external app via +an `ACTION_VIEW` intent. Reusing the existing player would give a consistent, in-app experience. + +The player therefore needs to be consumed by two places: `features:cells` and `app` (chat). The +project's dependency law is `kalium → core → features → app`, with the additional rule that features +must not depend on other features — shared logic goes into `core:*` or Kalium. That rules out the two +shortcuts: + +- Keeping it in `features:cells` and calling it from chat couples the entire "Wire Cells file storage + and collaboration" feature to chat, and the screen is bound to the cells navigation graph via + `@WireCellsDestination`. +- Creating a `features:video-player` module is illegal to consume from `features:cells` (feature → + feature dependencies are forbidden). + +Because the player is shared UI infrastructure used by multiple layers above `core`, its correct home +is a `core` module. + +## Decision + +Extract the video player into a new **`core:media-player`** module and consume it from both +`features:cells` and `app`. + +A new dedicated core module is preferred over folding it into the existing `core:media` module so that +the heavy media dependencies (`media3-exoplayer`, `media3-ui`, `coil3-video`) stay scoped to modules +that actually play video, rather than leaking to everything that already depends on `core:media` +(currently only `PingRinger`). The new module can later become the home for the other in-app media +viewers (image viewer, media gallery) if we choose to consolidate them. + +The two pieces that are currently cells-specific will be generalized during the move: + +- **Navigation:** replace `@WireCellsDestination` with a single shared destination registered through + `core:navigation`, so the player is navigable from any feature/app graph rather than only the cells + graph. +- **Dependency injection:** move the Metro wiring out of `CellsViewModelFactory` so `core:media-player` + provides its own `VideoPlayerViewModel`. + +The work is sequenced extract-then-reuse, so the risky refactor is validated against the existing +caller before chat depends on it: + +1. Create `core:media-player` and move the player files into it, generalizing navigation and DI. No + user-facing behavior change. +2. Migrate `features:cells` to navigate to the shared destination and delete its copy of the player. + This proves parity with the existing flow. +3. Wire chat in `app`: on tapping a video message, navigate to the shared destination (passing the + already-resolved local path / content URL) instead of firing the external `ACTION_VIEW` intent. + +## Consequences + +- A new module `core:media-player` is added; it is auto-discovered by `settings.gradle.kts` and built + with the `wire-android-library` convention plugin (copied from an existing core module, not from + `features/template`). It may depend on `core:ui-common`, `core:navigation`, and `core:di` — all + core → core, one-directional, no cycle. +- `features:cells` and `app` both depend on `core:media-player`; the `kalium → core → features → app` + direction is preserved and no cross-feature dependency is introduced. +- The video player becomes reusable from anywhere, and chat gains an in-app video experience instead + of delegating to an external app. +- The player's navigation destination is no longer part of the cells graph; callers navigate to the + shared destination. Any deep links or existing navigation to the cells-scoped destination must be + updated as part of Phase 2. +- The media3/Coil-video dependencies move to (and are scoped by) the new module. +- Follow-up opportunity (out of scope here): the cells in-app image viewer (`CellImageViewerScreen`) + and the app `MediaGalleryScreen` overlap; `core:media-player` is a natural future home for a unified + media viewer, to be handled as a separate ADR/change. diff --git a/features/cells/build.gradle.kts b/features/cells/build.gradle.kts index d18ff987b98..2ec3a318b98 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation("com.wire.kalium:kalium-cells") implementation(project(":core:di")) implementation(project(":core:ui-common")) + implementation(project(":core:media-player")) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) @@ -33,12 +34,8 @@ dependencies { implementation(libs.coil.core) implementation(libs.coil.gif) - 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/CellsMetroViewModelBindings.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsMetroViewModelBindings.kt index 913f02531e9..e4786a12da4 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 @@ -36,7 +36,6 @@ 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 @@ -118,19 +117,6 @@ object CellsMetroViewModelBindings { 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(), - ) - } - @Provides @IntoMap @ViewModelAssistedFactoryKey(AudioPlayerViewModel::class) 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 6b396d9042a..2d98ac212ee 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,6 @@ 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 @@ -223,14 +222,6 @@ class CellsViewModelFactory @Inject constructor( savedStateHandle = savedStateHandle, ) - internal fun cellVideoViewerViewModel( - context: Context, - savedStateHandle: SavedStateHandle - ) = VideoPlayerViewModel( - context = context, - savedStateHandle = savedStateHandle, - ) - internal fun 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 c8dd9b350fa..1965873683b 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 @@ -37,7 +37,6 @@ 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( @@ -93,8 +92,5 @@ fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel() @Composable fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel() -@Composable -fun cellVideoViewerViewModel(): VideoPlayerViewModel = cellsViewModel() - @Composable fun cellAudioPlayerViewModel(): AudioPlayerViewModel = cellsViewModel() diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/videoplayer/VideoPlayerScreen.kt index 497ee37da95..3bc5520504e 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 @@ -17,95 +17,16 @@ */ 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.mediaplayer.VideoPlayer 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 +/** + * Cells navigation entry point for the shared [VideoPlayer]. Reads the destination's [VideoViewerNavArgs] + * and delegates rendering + playback to the reusable player in `core:media-player`. + */ @WireCellsDestination( style = PopUpNavigationAnimation::class, navArgs = VideoViewerNavArgs::class, @@ -113,406 +34,12 @@ private const val CONTROLS_AUTO_HIDE_MS = 3_000L @Composable fun VideoPlayerScreen( navigator: WireNavigator, - modifier: Modifier = Modifier, - viewModel: VideoPlayerViewModel = cellVideoViewerViewModel(), + navArgs: VideoViewerNavArgs, ) { - 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, + VideoPlayer( + localPath = navArgs.localPath, + contentUrl = navArgs.contentUrl, + fileName = navArgs.fileName, 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/kalium b/kalium index 4721185f2f4..041f0a8a5f9 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 4721185f2f422fb0081d1ebe69d5c5753d3a6c9d +Subproject commit 041f0a8a5f9ce2a462e54212e15d4e02396e3b8a