diff --git a/app/build.gradle.kts b/app/build.gradle.kts index faab97578ab..aca6b7ea30c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -212,7 +212,8 @@ dependencies { implementationWithCoverage(projects.core.datastore) implementationWithCoverage(projects.core.di) implementationWithCoverage(projects.core.media) - implementationWithCoverage(projects.core.mediaPlayer) + implementationWithCoverage(projects.core.videoPlayer) + implementationWithCoverage(projects.core.audioPlayer) implementationWithCoverage(projects.core.notification) implementationWithCoverage(projects.core.navigation) implementationWithCoverage(projects.core.search) diff --git a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt index 22c93c6c327..34a3b44ab7f 100644 --- a/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt +++ b/app/src/main/kotlin/com/wire/android/di/metro/AppSessionViewModelGraph.kt @@ -17,6 +17,7 @@ */ package com.wire.android.di.metro +import com.wire.android.audioplayer.AudioPlayerManualViewModelFactoryMetroBindings import com.wire.android.di.CurrentAccount import com.wire.android.di.ImageLoadingModule import com.wire.android.di.SessionModule @@ -40,25 +41,25 @@ import com.wire.android.feature.cells.ui.CellsMetroViewModelBindings import com.wire.android.feature.meetings.ui.MeetingsManualViewModelFactoryMetroBindings import com.wire.android.feature.meetings.ui.MeetingsMetroViewModelBindings import com.wire.android.feature.sketch.SketchMetroViewModelBindings -import com.wire.android.mediaplayer.MediaPlayerManualViewModelFactoryMetroBindings import com.wire.android.search.SearchManualViewModelFactoryMetroBindings import com.wire.android.ui.authentication.AuthenticationViewModelGraph -import com.wire.android.ui.calling.CallingMetroViewModelBindings import com.wire.android.ui.calling.CallingManualViewModelFactoryMetroBindings -import com.wire.android.ui.common.CommonMetroViewModelBindings +import com.wire.android.ui.calling.CallingMetroViewModelBindings import com.wire.android.ui.common.CommonManualViewModelFactoryMetroBindings +import com.wire.android.ui.common.CommonMetroViewModelBindings import com.wire.android.ui.common.CoreUICommonManualViewModelFactoryMetroBindings -import com.wire.android.ui.debug.DebugMetroViewModelBindings import com.wire.android.ui.debug.DebugInfoManualViewModelFactoryMetroBindings import com.wire.android.ui.debug.DebugInfoViewModelGraph +import com.wire.android.ui.debug.DebugMetroViewModelBindings import com.wire.android.ui.home.HomeMetroViewModelBindings import com.wire.android.ui.home.HomeViewModelGraph -import com.wire.android.ui.home.conversations.ConversationSearchFolderMetroViewModelBindings import com.wire.android.ui.home.conversations.ConversationCoreManualViewModelFactoryMetroBindings import com.wire.android.ui.home.conversations.ConversationDetailsManualViewModelFactoryMetroBindings import com.wire.android.ui.home.conversations.ConversationSearchFolderManualViewModelFactoryMetroBindings +import com.wire.android.ui.home.conversations.ConversationSearchFolderMetroViewModelBindings import com.wire.android.ui.home.settings.SettingsManualViewModelFactoryMetroBindings import com.wire.android.util.ui.WireSessionImageLoader +import com.wire.android.videoplayer.VideoPlayerManualViewModelFactoryMetroBindings import com.wire.kalium.logic.data.user.UserId import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesTo @@ -110,7 +111,8 @@ annotation class MetroSessionScope SketchMetroViewModelBindings::class, CoreUICommonManualViewModelFactoryMetroBindings::class, SearchManualViewModelFactoryMetroBindings::class, - MediaPlayerManualViewModelFactoryMetroBindings::class, + VideoPlayerManualViewModelFactoryMetroBindings::class, + AudioPlayerManualViewModelFactoryMetroBindings::class, ImageLoadingModule::class, ] ) diff --git a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt index 8fe153c6165..57c3a5558f2 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/routes/media/MediaNavigation3Entries.kt @@ -11,7 +11,7 @@ import com.wire.android.navigation.navigation3.WireEntryProviderInstaller import com.wire.android.navigation.navigation3.WireNavigation3ResultType import com.wire.android.navigation.navigation3.WireNavigation3Runtime import com.wire.android.navigation.navigation3.wireEntry -import com.wire.android.mediaplayer.VideoPlayer +import com.wire.android.videoplayer.VideoPlayer import com.wire.android.ui.home.FeatureFlagState import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.checkAssetRestrictionsViewModel diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt index 5da7f936ddf..d16880ad264 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationMessageComposer.kt @@ -58,6 +58,7 @@ internal fun ConversationMessageComposer( onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onAudioClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, @@ -115,6 +116,7 @@ internal fun ConversationMessageComposer( onAssetClicked = onAssetItemClicked, onImageClicked = onImageFullScreenMode, onVideoClicked = onVideoClick, + onAudioClicked = onAudioClick, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, onResetSessionClicked = onResetSessionClicked, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt index 17e88003738..f0fff9ff00f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationNavigation3Entries.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import com.wire.android.feature.cells.navigation.AudioPlayerRoute import com.wire.android.feature.sketch.navigation.DrawingCanvasNavigation3ResultType import com.wire.android.feature.sketch.navigation.DrawingCanvasRoute import com.wire.android.navigation.navigation3.WireEntryPresentation @@ -251,6 +252,14 @@ private fun ConversationNavigation3Entry( ) } + override fun openAudioPlayer(localPath: String?, contentUrl: String?, fileName: String?) { + runtime.navigator.navigate( + WireNavigationCommand( + AudioPlayerRoute(route.sessionId, localPath, contentUrl, fileName) + ) + ) + } + override fun openDrawingCanvas(conversationName: String, tempWritableUri: Uri?) { drawingRequestId = runtime.navigateForResult( DrawingCanvasRoute( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt index 5a429b66d70..5538f6703a4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationRouteScreen.kt @@ -51,6 +51,7 @@ internal interface ConversationRouteScreenNavigation { ) fun openVideoPlayer(localPath: String?, contentUrl: String?, fileName: String?) + fun openAudioPlayer(localPath: String?, contentUrl: String?, fileName: String?) fun openDrawingCanvas( conversationName: String, 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 de7ba41a68a..8317112227b 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 @@ -438,6 +438,7 @@ internal fun ConversationScreenRouteContent( } }, onVideoClick = navigation::openVideoPlayer, + onAudioClick = navigation::openAudioPlayer, onStartCall = { conversationCallViewModel.startCallIfPossible(conversationInfoViewModel.conversationInfoViewState.conversationType) }, @@ -642,6 +643,7 @@ private fun ConversationScreenContent( onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onAudioClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onStartCall: () -> Unit, onJoinCall: () -> Unit, onReactionClick: (messageId: String, reactionEmoji: String) -> Unit, @@ -754,6 +756,7 @@ private fun ConversationScreenContent( onAssetItemClicked = onAssetItemClicked, onImageFullScreenMode = onImageFullScreenMode, onVideoClick = onVideoClick, + onAudioClick = onAudioClick, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, onOpenProfile = onOpenProfile, @@ -896,6 +899,7 @@ fun PreviewConversationScreen() = WireTheme { onAssetItemClicked = { }, onImageFullScreenMode = { _, _, _ -> }, onVideoClick = { _, _, _ -> }, + onAudioClick = { _, _, _ -> }, 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 62274c1d983..613553b17ad 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 @@ -30,6 +30,7 @@ sealed class MessageClickActions { 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 onAudioClicked: (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 = { _, _ -> } @@ -48,6 +49,7 @@ sealed class MessageClickActions { 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 onAudioClicked: (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 007570c5d4b..8ccc18b1c65 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 @@ -73,6 +73,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean, String?) -> Unit, onVideoClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onAudioClicked: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onProfileClicked: (senderId: MessageSenderId) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -121,6 +122,7 @@ internal fun UIMessage.Regular.MessageContentAndStatus( onImageClick = onImageClickable, onMultipartImageClick = onMultipartImageClickable, onMultipartVideoClick = onVideoClicked, + onMultipartAudioClick = onAudioClicked, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -170,6 +172,7 @@ private fun MessageContent( onImageClick: Clickable, onMultipartImageClick: (String) -> Unit, onMultipartVideoClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onMultipartAudioClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, onOpenProfile: (senderId: MessageSenderId) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -464,6 +467,7 @@ private fun MessageContent( messageStyle = messageStyle, onImageAttachmentClick = onMultipartImageClick, onVideoAttachmentClick = onMultipartVideoClick, + onAudioAttachmentClick = onMultipartAudioClick, ) } 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 59184ca075e..b4523ff62a1 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 @@ -96,6 +96,7 @@ fun MessageContentItem( onAssetClicked = clickActions.onAssetClicked, onImageClicked = clickActions.onImageClicked, onVideoClicked = clickActions.onVideoClicked, + onAudioClicked = clickActions.onAudioClicked, 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 d9581475c57..2ab203b4fd4 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 @@ -59,6 +59,7 @@ fun MultipartAttachmentsView( messageStyle: MessageStyle, onImageAttachmentClick: (String) -> Unit, onVideoAttachmentClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, + onAudioAttachmentClick: (localPath: String?, contentUrl: String?, fileName: String?) -> Unit, modifier: Modifier = Modifier, viewModel: MultipartAttachmentsViewModel = when { LocalInspectionMode.current -> MultipartAttachmentsViewModelPreview @@ -93,6 +94,9 @@ fun MultipartAttachmentsView( openInVideoPlayer = { att -> onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) }, + openInAudioPlayer = { att -> + onAudioAttachmentClick(att.localPath, att.contentUrl, att.fileName) + }, ) }, ) @@ -123,8 +127,11 @@ fun MultipartAttachmentsView( viewModel.onClick( attachment = it, openInImageViewer = onImageAttachmentClick, - openInVideoPlayer = { att -> - onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) + openInVideoPlayer = { attachment -> + onVideoAttachmentClick(attachment.localPath, attachment.contentUrl, attachment.fileName) + }, + openInAudioPlayer = { attachment -> + onAudioAttachmentClick(attachment.localPath, attachment.contentUrl, attachment.fileName) }, ) }, @@ -138,8 +145,11 @@ fun MultipartAttachmentsView( viewModel.onClick( attachment = it, openInImageViewer = onImageAttachmentClick, - openInVideoPlayer = { att -> - onVideoAttachmentClick(att.localPath, att.contentUrl, att.fileName) + openInVideoPlayer = { attachment -> + onVideoAttachmentClick(attachment.localPath, attachment.contentUrl, attachment.fileName) + }, + openInAudioPlayer = { attachment -> + onAudioAttachmentClick(attachment.localPath, attachment.contentUrl, attachment.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 e074186eb1e..96ec79ec1d2 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 @@ -22,6 +22,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.appLogger import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.domain.model.AttachmentFileType.AUDIO import com.wire.android.feature.cells.domain.model.AttachmentFileType.IMAGE import com.wire.android.feature.cells.domain.model.AttachmentFileType.PDF import com.wire.android.feature.cells.domain.model.AttachmentFileType.VIDEO @@ -62,6 +63,7 @@ interface MultipartAttachmentsViewModel { attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInAudioPlayer: (MultipartAttachmentUi) -> Unit, ) fun mapAttachment(attachment: MessageAttachment): MultipartAttachmentUi { val isAvailableOffline = attachment.assetId() in offlineAttachmentIds.value @@ -128,6 +130,7 @@ object MultipartAttachmentsViewModelPreview : MultipartAttachmentsViewModel { attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInAudioPlayer: (MultipartAttachmentUi) -> Unit, ) {} override fun onAttachmentsVisible(attachments: List) {} override fun onAttachmentsHidden(attachments: List) {} @@ -164,10 +167,12 @@ class MultipartAttachmentsViewModelImpl @AssistedInject constructor( loadWireCellConfig() } + @Suppress("CyclomaticComplexMethod") override fun onClick( attachment: MultipartAttachmentUi, openInImageViewer: (String) -> Unit, openInVideoPlayer: (MultipartAttachmentUi) -> Unit, + openInAudioPlayer: (MultipartAttachmentUi) -> Unit, ) { when { attachment.isImage() && !attachment.fileNotFound() -> openInImageViewer(attachment.uuid) @@ -181,6 +186,9 @@ class MultipartAttachmentsViewModelImpl @AssistedInject constructor( attachment.isVideo() && (attachment.localFileAvailable() || attachment.canOpenWithUrl()) -> openInVideoPlayer(attachment) + attachment.isAudio() && (attachment.localFileAvailable() || attachment.canOpenWithUrl()) -> + openInAudioPlayer(attachment) + attachment.localFileAvailable() -> openLocalFile(attachment) attachment.canOpenWithUrl() -> openUrl(attachment) else -> downloadAsset(attachment) @@ -275,6 +283,8 @@ private fun MultipartAttachmentUi.isImage() = AttachmentFileType.fromMimeType(mi private fun MultipartAttachmentUi.isVideo() = assetType == VIDEO +private fun MultipartAttachmentUi.isAudio() = assetType == AUDIO + private fun MessageAttachment.isMediaAttachment() = when (AttachmentFileType.fromMimeType(mimeType())) { IMAGE, VIDEO -> true @@ -283,4 +293,4 @@ private fun MessageAttachment.isMediaAttachment() = private fun MultipartAttachmentUi.fileNotFound() = transferStatus == AssetTransferStatus.NOT_FOUND private fun MultipartAttachmentUi.localFileAvailable() = localPath != null -private fun MultipartAttachmentUi.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, PDF) +private fun MultipartAttachmentUi.canOpenWithUrl() = contentUrl != null && assetType in listOf(IMAGE, VIDEO, AUDIO, PDF) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt index d515c41f41c..ad732fcb983 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaNavArgs.kt @@ -15,6 +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.ui.sharing import android.net.Uri 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 b26d87e3df3..6e65c82a6b1 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) } } @@ -203,7 +203,8 @@ class MultipartAttachmentsViewModelTest { transferStatus = AssetTransferStatus.NOT_FOUND, ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInAudioPlayer = { }, ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -223,7 +224,8 @@ class MultipartAttachmentsViewModelTest { transferStatus = AssetTransferStatus.NOT_FOUND, ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInAudioPlayer = { }, ) coVerify(exactly = 0) { callback.invoke(testAttachmentUi.uuid) } @@ -243,7 +245,8 @@ class MultipartAttachmentsViewModelTest { localPath = "local/path", ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInAudioPlayer = { }, ) coVerify(exactly = 1) { arrangement.fileManager.openWithExternalApp(any(), any(), any(), any()) } @@ -262,7 +265,8 @@ class MultipartAttachmentsViewModelTest { contentUrl = "content/url", ), openInImageViewer = callback, - openInVideoPlayer = { } + openInVideoPlayer = { }, + openInAudioPlayer = { }, ) coVerify(exactly = 1) { arrangement.fileManager.openUrlWithExternalApp(any(), any(), any()) } diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability index df2194c2e57..e86e91e213d 100644 --- a/app/stability/app-devDebug.stability +++ b/app/stability/app-devDebug.stability @@ -4321,7 +4321,7 @@ private fun com.wire.android.ui.home.conversations.ConversationFoldersNavigation - runtime: STABLE (marked @Stable or @Immutable) @Composable -internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer(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 +internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer(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 = \, onAudioClick: 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: @@ -4343,6 +4343,7 @@ internal fun com.wire.android.ui.home.conversations.ConversationMessageComposer( - onAssetItemClicked: STABLE (function type) - onImageFullScreenMode: STABLE (function type) - onVideoClick: STABLE (function type) + - onAudioClick: STABLE (function type) - onReactionClicked: STABLE (function type) - onResetSessionClicked: STABLE (function type) - onOpenProfile: STABLE (function type) @@ -4399,7 +4400,7 @@ internal fun com.wire.android.ui.home.conversations.ConversationRouteScreen(navi - messageAttachmentsViewModel: UNSTABLE (has mutable properties or unstable members) @Composable -private fun com.wire.android.ui.home.conversations.ConversationScreenContent(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 +private fun com.wire.android.ui.home.conversations.ConversationScreenContent(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 = \, onAudioClick: 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: @@ -4421,6 +4422,7 @@ private fun com.wire.android.ui.home.conversations.ConversationScreenContent(ban - onAssetItemClicked: STABLE (function type) - onImageFullScreenMode: STABLE (function type) - onVideoClick: STABLE (function type) + - onAudioClick: STABLE (function type) - onStartCall: STABLE (function type) - onJoinCall: STABLE (function type) - onReactionClick: STABLE (function type) @@ -6412,7 +6414,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, 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 +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 = \, onMultipartAudioClick: 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: @@ -6425,6 +6427,7 @@ private fun com.wire.android.ui.home.conversations.messages.item.MessageContent( - onImageClick: STABLE (class with no mutable properties) - onMultipartImageClick: STABLE (function type) - onMultipartVideoClick: STABLE (function type) + - onMultipartAudioClick: STABLE (function type) - onOpenProfile: STABLE (function type) - onLinkClick: STABLE (function type) - onReplyClick: STABLE (class with no mutable properties) @@ -6432,7 +6435,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, 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 +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 = \, onAudioClicked: 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: @@ -6443,6 +6446,7 @@ internal fun com.wire.android.ui.home.conversations.messages.item.MessageContent - onAssetClicked: STABLE (function type) - onImageClicked: STABLE (function type) - onVideoClicked: STABLE (function type) + - onAudioClicked: STABLE (function type) - onProfileClicked: STABLE (function type) - onLinkClicked: STABLE (function type) - onReplyClicked: STABLE (function type) @@ -7182,7 +7186,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, onVideoAttachmentClick: kotlin.Function3<@[ParameterName(name = \, 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 = \, onAudioAttachmentClick: 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: @@ -7191,6 +7195,7 @@ public fun com.wire.android.ui.home.conversations.model.messagetypes.multipart.M - messageStyle: STABLE (class with no mutable properties) - onImageAttachmentClick: STABLE (function type) - onVideoAttachmentClick: STABLE (function type) + - onAudioAttachmentClick: STABLE (function type) - modifier: STABLE (marked @Stable or @Immutable) - viewModel: RUNTIME (requires runtime check) diff --git a/core/audio-player/build.gradle.kts b/core/audio-player/build.gradle.kts new file mode 100644 index 00000000000..d9607f27abd --- /dev/null +++ b/core/audio-player/build.gradle.kts @@ -0,0 +1,41 @@ +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) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.wire.android.audioplayer" +} + +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) + + 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"))) + ksp(project(":ksp")) +} diff --git a/core/media-player/lint-baseline.xml b/core/audio-player/lint-baseline.xml similarity index 100% rename from core/media-player/lint-baseline.xml rename to core/audio-player/lint-baseline.xml diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlaybackState.kt similarity index 94% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt rename to core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlaybackState.kt index 83cb0dc1194..55303f6eada 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlaybackState.kt +++ b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlaybackState.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.audioplayer +package com.wire.android.audioplayer data class AudioPlaybackState( val isPlaying: Boolean = false, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayer.kt similarity index 54% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt rename to core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayer.kt index 3e3fe4ea692..bffa30d3eaf 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerScreen.kt +++ b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayer.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.audioplayer +package com.wire.android.audioplayer import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing @@ -40,6 +40,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -59,7 +60,6 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.wire.android.feature.cells.R import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.preview.MultipleThemePreviews @@ -68,19 +68,32 @@ import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.typography import com.wire.android.ui.theme.WireTheme -import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds private const val SKIP_S = 12 -private val SKIP_MS = SKIP_S.milliseconds.inWholeMilliseconds.toInt() +private val SKIP_MS = SKIP_S.seconds.inWholeMilliseconds.toInt() +internal fun skipBackTarget(currentPositionMs: Int): Int = (currentPositionMs - SKIP_MS).coerceAtLeast(0) + +internal fun skipForwardTarget(currentPositionMs: Int, durationMs: Int): Int = + (currentPositionMs + SKIP_MS).coerceIn(0, durationMs.coerceAtLeast(0)) + +/** + * Reusable full-screen audio player. Plays either a local file ([localPath]) or a remote + * [contentUrl]. Callers own navigation via [onNavigateBack]; the ViewModel is resolved from the + * shared audio-player Metro graph so any module can host this screen. + */ @Composable -internal fun CellAudioPlayerRouteScreen( +fun AudioPlayer( + localPath: String?, + contentUrl: String?, + fileName: String?, onNavigateBack: () -> Unit, - viewModel: AudioPlayerViewModel, modifier: Modifier = Modifier, + viewModel: AudioPlayerViewModel = audioPlayerViewModel(localPath, contentUrl, fileName), ) { val state by viewModel.state.collectAsStateWithLifecycle() - CellAudioPlayerContent( + AudioPlayerContent( state = state, fileName = viewModel.fileName, onTogglePlayPause = viewModel::togglePlayPause, @@ -93,7 +106,7 @@ internal fun CellAudioPlayerRouteScreen( @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -internal fun CellAudioPlayerContent( +internal fun AudioPlayerContent( state: AudioPlaybackState, fileName: String?, onTogglePlayPause: () -> Unit, @@ -116,7 +129,7 @@ internal fun CellAudioPlayerContent( modifier = modifier, topBar = { WireCenterAlignedTopAppBar( - title = fileName ?: stringResource(R.string.conversation_files_title), + title = fileName ?: stringResource(R.string.audio_player_title), navigationIconType = NavigationIconType.Back(), onNavigationPressed = ::stopAndBack, ) @@ -150,126 +163,126 @@ internal fun CellAudioPlayerContent( modifier = Modifier.fillMaxWidth(), ) { Slider( - value = progress, - onValueChange = { value -> - isSeeking = true - seekProgress = value - }, - onValueChangeFinished = { - onSeek((seekProgress * state.durationMs).toInt()) - isSeeking = false - }, - colors = SliderDefaults.colors( - thumbColor = colorsScheme().inverseSurface, - activeTrackColor = colorsScheme().inverseSurface, - inactiveTrackColor = colorsScheme().surfaceContainerHighest, - ), - modifier = Modifier.fillMaxWidth(), - ) + value = progress, + onValueChange = { value -> + isSeeking = true + seekProgress = value + }, + onValueChangeFinished = { + onSeek((seekProgress * state.durationMs).toInt()) + isSeeking = false + }, + colors = SliderDefaults.colors( + thumbColor = colorsScheme().inverseSurface, + activeTrackColor = colorsScheme().inverseSurface, + inactiveTrackColor = colorsScheme().surfaceContainerHighest, + ), + modifier = Modifier.fillMaxWidth(), + ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensions().spacing4x), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = state.currentPositionMs.toTimeString(), - color = colorsScheme().inverseSurface, - style = typography().subline01, - ) - Text( - text = state.durationMs.toTimeString(), - color = colorsScheme().inverseSurface, - style = typography().subline01, - ) - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensions().spacing4x), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = state.currentPositionMs.toTimeString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + Text( + text = state.durationMs.toTimeString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) + } - Spacer(modifier = Modifier.height(dimensions().spacing24x)) + Spacer(modifier = Modifier.height(dimensions().spacing24x)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + onSeek(skipBackTarget(state.currentPositionMs)) + } ) { - IconButton( - onClick = { - onSeek((state.currentPositionMs - SKIP_MS).coerceAtLeast(0)) - } - ) { - Column { - Icon( - painter = painterResource(R.drawable.ic_skip_back), - contentDescription = null, - tint = colorsScheme().inverseSurface, - modifier = Modifier.size(dimensions().spacing16x), - ) + Column { + Icon( + painter = painterResource(R.drawable.ic_skip_back), + contentDescription = null, + tint = colorsScheme().inverseSurface, + modifier = Modifier.size(dimensions().spacing16x), + ) - Text( - text = SKIP_S.toString(), - color = colorsScheme().inverseSurface, - style = typography().subline01, - ) - } + Text( + text = SKIP_S.toString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) } + } - Spacer(modifier = Modifier.width(dimensions().spacing24x)) + Spacer(modifier = Modifier.width(dimensions().spacing24x)) - val buttonScale by animateFloatAsState( - targetValue = if (state.isPrepared) 1f else 0.7f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium, - ), - label = "audioButtonScale", - ) + val buttonScale by animateFloatAsState( + targetValue = if (state.isPrepared) 1f else 0.7f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "audioButtonScale", + ) - Box( - modifier = Modifier - .size(dimensions().spacing64x) - .scale(buttonScale) - .clip(CircleShape), - contentAlignment = Alignment.Center, + Box( + modifier = Modifier + .size(dimensions().spacing64x) + .scale(buttonScale) + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + val iconRes = if (state.isPlaying) R.drawable.ic_pause else R.drawable.ic_play + IconButton( + onClick = { if (state.isPrepared) onTogglePlayPause() }, + modifier = Modifier.fillMaxSize(), + colors = IconButtonDefaults.iconButtonColors( + containerColor = colorsScheme().inverseSurface, + ) ) { - val iconRes = if (state.isPlaying) R.drawable.ic_pause else R.drawable.ic_play - IconButton( - onClick = { if (state.isPrepared) onTogglePlayPause() }, - modifier = Modifier.fillMaxSize(), - colors = IconButtonDefaults.iconButtonColors( - containerColor = colorsScheme().inverseSurface, - ) - ) { - Icon( - painter = painterResource(iconRes), - contentDescription = null, - tint = colorsScheme().inverseOnSurface, - modifier = Modifier.size(dimensions().spacing40x), - ) - } + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = colorsScheme().inverseOnSurface, + modifier = Modifier.size(dimensions().spacing40x), + ) } + } - Spacer(modifier = Modifier.width(dimensions().spacing24x)) + Spacer(modifier = Modifier.width(dimensions().spacing24x)) - IconButton( - onClick = { - onSeek((state.currentPositionMs + SKIP_MS).coerceAtMost(state.durationMs)) - } - ) { - Column { - Icon( - painter = painterResource(R.drawable.ic_skip_forward), - contentDescription = null, - tint = colorsScheme().inverseSurface, - modifier = Modifier.size(dimensions().spacing16x), - ) - Text( - text = SKIP_S.toString(), - color = colorsScheme().inverseSurface, - style = typography().subline01, - ) - } + IconButton( + onClick = { + onSeek(skipForwardTarget(state.currentPositionMs, state.durationMs)) + } + ) { + Column { + Icon( + painter = painterResource(R.drawable.ic_skip_forward), + contentDescription = null, + tint = colorsScheme().inverseSurface, + modifier = Modifier.size(dimensions().spacing16x), + ) + Text( + text = SKIP_S.toString(), + color = colorsScheme().inverseSurface, + style = typography().subline01, + ) } } + } Spacer(modifier = Modifier.height(dimensions().spacing32x)) } } @@ -309,7 +322,7 @@ private fun EqualizerBars(isPlaying: Boolean) { modifier = Modifier .width(dimensions().spacing6x) .height(maxBarHeightPx * fraction) - .clip(androidx.compose.foundation.shape.RoundedCornerShape(dimensions().spacing3x)) + .clip(RoundedCornerShape(dimensions().spacing3x)) .background(colorsScheme().inverseSurface) ) } @@ -326,9 +339,9 @@ private fun Int.toTimeString(): String { @MultipleThemePreviews @Composable -fun PreviewCellAudioPlayerScreen() { +fun PreviewAudioPlayer() { WireTheme { - CellAudioPlayerContent( + AudioPlayerContent( state = AudioPlaybackState(isPlaying = true, isPrepared = true, currentPositionMs = 30000, durationMs = 120000), fileName = "awesome_track.mp3", onTogglePlayPause = {}, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModel.kt similarity index 87% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt rename to core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModel.kt index 232cfb603fc..a3956a48c64 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModel.kt +++ b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModel.kt @@ -15,14 +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.audioplayer +package com.wire.android.audioplayer import android.content.Context import android.media.MediaPlayer import android.net.Uri -import java.io.File import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.wire.android.di.ApplicationContext +import com.wire.android.di.metro.WireAssistedViewModelBinding import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject @@ -34,9 +35,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.io.File +/** + * Playback ViewModel for the reusable [AudioPlayer]. Plays either a local file ([localPath]) or a + * remote [contentUrl]. The arguments are passed in through assisted injection + */ +@WireAssistedViewModelBinding(AudioPlayerManualViewModelFactoryGroup::class) class AudioPlayerViewModel @AssistedInject constructor( - @Assisted context: Context, + @ApplicationContext context: Context, @Assisted val localPath: String?, @Assisted val contentUrl: String?, @Assisted val fileName: String?, @@ -44,7 +51,7 @@ class AudioPlayerViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(context: Context, localPath: String?, contentUrl: String?, fileName: String?): AudioPlayerViewModel + fun create(localPath: String?, contentUrl: String?, fileName: String?): AudioPlayerViewModel } private val _state = MutableStateFlow(AudioPlaybackState()) @@ -94,12 +101,14 @@ class AudioPlayerViewModel @AssistedInject constructor( seekTo(0) play() } + current.isPlaying -> pause() else -> play() } } fun seekTo(positionMs: Int) { + if (!_state.value.isPrepared) return mediaPlayer.seekTo(positionMs) _state.update { it.copy(currentPositionMs = positionMs) } } diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelGraph.kt similarity index 50% rename from features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt rename to core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelGraph.kt index 47f43aaea93..cab8584ea49 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerNavArgs.kt +++ b/core/audio-player/src/main/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelGraph.kt @@ -15,10 +15,25 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.audioplayer +@file:Suppress("MatchingDeclarationName") -data class AudioPlayerNavArgs( - val localPath: String? = null, - val contentUrl: String? = null, - val fileName: String? = null, -) +package com.wire.android.audioplayer + +import androidx.compose.runtime.Composable +import com.wire.android.di.metro.WireAssistedViewModelFactoryGroup +import com.wire.android.di.metro.wireAssistedMetroViewModel + +@WireAssistedViewModelFactoryGroup +object AudioPlayerManualViewModelFactoryGroup + +@Composable +fun audioPlayerViewModel( + localPath: String?, + contentUrl: String?, + fileName: String?, +): AudioPlayerViewModel = + wireAssistedMetroViewModel( + instanceKey = "audio_player_${localPath ?: contentUrl}" + ) { + audioPlayerViewModel(localPath, contentUrl, fileName) + } diff --git a/core/media-player/src/main/res/drawable/ic_pause.xml b/core/audio-player/src/main/res/drawable/ic_pause.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_pause.xml rename to core/audio-player/src/main/res/drawable/ic_pause.xml diff --git a/core/media-player/src/main/res/drawable/ic_play.xml b/core/audio-player/src/main/res/drawable/ic_play.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_play.xml rename to core/audio-player/src/main/res/drawable/ic_play.xml diff --git a/features/cells/src/main/res/drawable/ic_skip_back.xml b/core/audio-player/src/main/res/drawable/ic_skip_back.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_skip_back.xml rename to core/audio-player/src/main/res/drawable/ic_skip_back.xml diff --git a/features/cells/src/main/res/drawable/ic_skip_forward.xml b/core/audio-player/src/main/res/drawable/ic_skip_forward.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_skip_forward.xml rename to core/audio-player/src/main/res/drawable/ic_skip_forward.xml diff --git a/core/audio-player/src/main/res/values/strings.xml b/core/audio-player/src/main/res/values/strings.xml new file mode 100644 index 00000000000..26f2c101b6d --- /dev/null +++ b/core/audio-player/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Audio + \ No newline at end of file diff --git a/core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerSkipTest.kt b/core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerSkipTest.kt new file mode 100644 index 00000000000..fdb75082647 --- /dev/null +++ b/core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerSkipTest.kt @@ -0,0 +1,49 @@ +/* + * 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.audioplayer + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AudioPlayerSkipTest { + + @Test + fun givenPositionInMiddle_whenSkippingBack_thenTargetIsTwelveSecondsEarlier() { + assertEquals(18_000, skipBackTarget(currentPositionMs = 30_000)) + } + + @Test + fun givenPositionInMiddle_whenSkippingForward_thenTargetIsTwelveSecondsLater() { + assertEquals(42_000, skipForwardTarget(currentPositionMs = 30_000, durationMs = 120_000)) + } + + @Test + fun givenPositionCloserToStartThanSkip_whenSkippingBack_thenTargetIsClampedToZero() { + assertEquals(0, skipBackTarget(currentPositionMs = 5_000)) + } + + @Test + fun givenPositionCloserToEndThanSkip_whenSkippingForward_thenTargetIsClampedToDuration() { + assertEquals(120_000, skipForwardTarget(currentPositionMs = 115_000, durationMs = 120_000)) + } + + @Test + fun givenUnknownDuration_whenSkippingForward_thenTargetIsZero() { + assertEquals(0, skipForwardTarget(currentPositionMs = 0, durationMs = 0)) + } +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt b/core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelTest.kt similarity index 79% rename from features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt rename to core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelTest.kt index f6c10627226..7a687d0b673 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/audioplayer/AudioPlayerViewModelTest.kt +++ b/core/audio-player/src/test/kotlin/com/wire/android/audioplayer/AudioPlayerViewModelTest.kt @@ -15,13 +15,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ -package com.wire.android.feature.cells.ui.audioplayer +package com.wire.android.audioplayer import android.content.Context import android.media.MediaPlayer import android.net.Uri import androidx.lifecycle.ViewModel -import com.wire.android.config.CoroutineTestExtension import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -31,26 +30,37 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class) class AudioPlayerViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + @AfterEach fun tearDown() { + Dispatchers.resetMain() unmockkAll() } @Test fun givenLocalPath_whenInitialized_thenDataSourceIsSetWithFileUriAndPrepared() = runTest { val (arrangement, _) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() verify { Uri.fromFile(any()) } @@ -61,7 +71,7 @@ class AudioPlayerViewModelTest { @Test fun givenContentUrl_whenInitialized_thenDataSourceIsSetWithParsedUri() = runTest { val (arrangement, _) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(contentUrl = "https://wire.com/audio.mp3")) + .withSource(contentUrl = "https://wire.com/audio.mp3") .arrange() verify { Uri.parse("https://wire.com/audio.mp3") } @@ -72,7 +82,7 @@ class AudioPlayerViewModelTest { @Test fun givenNoSource_whenInitialized_thenDataSourceIsNotSet() = runTest { Arrangement() - .withNavArgs(AudioPlayerNavArgs()) + .withSource() .arrange() verify(exactly = 0) { anyConstructed().setDataSource(any(), any()) } @@ -82,7 +92,7 @@ class AudioPlayerViewModelTest { @Test fun givenSetDataSourceThrows_whenInitialized_thenExceptionIsHandledSilently() = runTest { val (_, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .withSetDataSourceThrowing() .arrange() @@ -90,14 +100,12 @@ class AudioPlayerViewModelTest { } @Test - fun givenNavArgs_whenInitialized_thenExposedAsProperties() = runTest { + fun givenArgs_whenInitialized_thenExposedAsProperties() = runTest { val (_, viewModel) = Arrangement() - .withNavArgs( - AudioPlayerNavArgs( - localPath = "/tmp/audio.mp3", - contentUrl = "https://wire.com/audio.mp3", - fileName = "audio.mp3", - ) + .withSource( + localPath = "/tmp/audio.mp3", + contentUrl = "https://wire.com/audio.mp3", + fileName = "audio.mp3", ) .arrange() @@ -109,7 +117,7 @@ class AudioPlayerViewModelTest { @Test fun givenPlayerPrepares_whenOnPrepared_thenStateHasDurationAndIsPrepared() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .withDuration(5000) .arrange() @@ -122,7 +130,7 @@ class AudioPlayerViewModelTest { @Test fun givenNotPrepared_whenPlay_thenNothingHappens() = runTest { val (_, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() viewModel.play() @@ -134,7 +142,7 @@ class AudioPlayerViewModelTest { @Test fun givenPrepared_whenPlay_thenStartsAndUpdatesStateAndPollsPosition() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .withCurrentPosition(42) .arrange() arrangement.triggerPrepared() @@ -152,7 +160,7 @@ class AudioPlayerViewModelTest { @Test fun givenNotPlaying_whenPause_thenNothingHappens() = runTest { val (_, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() viewModel.pause() @@ -163,7 +171,7 @@ class AudioPlayerViewModelTest { @Test fun givenPlaying_whenPause_thenPausesAndUpdatesState() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.triggerPrepared() viewModel.play() @@ -177,7 +185,7 @@ class AudioPlayerViewModelTest { @Test fun givenNotPlaying_whenTogglePlayPause_thenStartsPlaying() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.triggerPrepared() @@ -192,7 +200,7 @@ class AudioPlayerViewModelTest { @Test fun givenPlaying_whenTogglePlayPause_thenPauses() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.triggerPrepared() viewModel.play() @@ -206,7 +214,7 @@ class AudioPlayerViewModelTest { @Test fun givenCompleted_whenTogglePlayPause_thenSeeksToStartAndPlays() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.triggerPrepared() arrangement.triggerCompletion() @@ -222,10 +230,11 @@ class AudioPlayerViewModelTest { } @Test - fun whenSeekTo_thenMediaPlayerSeeksAndStateUpdated() = runTest { - val (_, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + fun givenPrepared_whenSeekTo_thenMediaPlayerSeeksAndStateUpdated() = runTest { + val (arrangement, viewModel) = Arrangement() + .withSource(localPath = "/tmp/audio.mp3") .arrange() + arrangement.triggerPrepared() viewModel.seekTo(1234) @@ -233,10 +242,22 @@ class AudioPlayerViewModelTest { assertEquals(1234, viewModel.state.value.currentPositionMs) } + @Test + fun givenNotPrepared_whenSeekTo_thenNothingHappens() = runTest { + val (_, viewModel) = Arrangement() + .withSource(localPath = "/tmp/audio.mp3") + .arrange() + + viewModel.seekTo(1234) + + verify(exactly = 0) { anyConstructed().seekTo(any()) } + assertEquals(0, viewModel.state.value.currentPositionMs) + } + @Test fun givenPlaying_whenCompletionFires_thenStateIsCompletedAndNotPlaying() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.triggerPrepared() viewModel.play() @@ -250,7 +271,7 @@ class AudioPlayerViewModelTest { @Test fun whenCleared_thenPlayerIsStoppedAndReleased() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .arrange() arrangement.clear(viewModel) @@ -262,7 +283,7 @@ class AudioPlayerViewModelTest { @Test fun givenStopThrows_whenCleared_thenReleaseIsStillCalled() = runTest { val (arrangement, viewModel) = Arrangement() - .withNavArgs(AudioPlayerNavArgs(localPath = "/tmp/audio.mp3")) + .withSource(localPath = "/tmp/audio.mp3") .withStopThrowing() .arrange() @@ -281,7 +302,9 @@ class AudioPlayerViewModelTest { private val preparedListenerSlot = slot() private val completionListenerSlot = slot() - private var navArgs = AudioPlayerNavArgs(localPath = "/tmp/audio.mp3") + private var localPath: String? = "/tmp/audio.mp3" + private var contentUrl: String? = null + private var fileName: String? = null init { mockkStatic(Uri::class) @@ -301,7 +324,15 @@ class AudioPlayerViewModelTest { every { anyConstructed().currentPosition } returns 0 } - fun withNavArgs(args: AudioPlayerNavArgs) = apply { navArgs = args } + fun withSource( + localPath: String? = null, + contentUrl: String? = null, + fileName: String? = null, + ) = apply { + this.localPath = localPath + this.contentUrl = contentUrl + this.fileName = fileName + } fun withDuration(durationMs: Int) = apply { every { preparedMp.duration } returns durationMs @@ -333,13 +364,12 @@ class AudioPlayerViewModelTest { method.invoke(viewModel) } - fun arrange(): Pair { - return this to AudioPlayerViewModel( + fun arrange(): Pair = + this to AudioPlayerViewModel( context = context, - localPath = navArgs.localPath, - contentUrl = navArgs.contentUrl, - fileName = navArgs.fileName, + localPath = localPath, + contentUrl = contentUrl, + fileName = fileName, ) - } } } diff --git a/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt b/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt deleted file mode 100644 index 626cf431628..00000000000 --- a/core/media-player/src/test/kotlin/com/wire/android/mediaplayer/MediaPlayerAssistedFactorySourceTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Wire - * Copyright (C) 2026 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - */ -package com.wire.android.mediaplayer - -import java.io.File -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -internal class MediaPlayerAssistedFactorySourceTest { - - @Test - fun givenVideoRouteArguments_whenInspectingCreation_thenNarrowAssistedFactoryOwnsOnlyThoseArguments() { - val viewModel = source("VideoPlayerViewModel.kt") - val graph = source("MediaPlayerViewModelGraph.kt") - - assertTrue(viewModel.contains("class VideoPlayerViewModel @AssistedInject constructor")) - assertTrue(viewModel.contains("@ApplicationContext context: Context")) - assertTrue(viewModel.contains("@Assisted val localPath: String?")) - assertTrue(viewModel.contains("@Assisted val contentUrl: String?")) - assertTrue(viewModel.contains("@Assisted val fileName: String?")) - assertTrue(viewModel.contains("@AssistedFactory\n interface Factory")) - assertTrue(viewModel.contains("@WireAssistedViewModelBinding(MediaPlayerManualViewModelFactoryGroup::class)")) - assertTrue(graph.contains("@WireAssistedViewModelFactoryGroup")) - assertFalse(File("src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerMetroViewModelBindings.kt").exists()) - assertFalse(File("src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelFactory.kt").exists()) - } - - private fun source(name: String) = - File("src/main/kotlin/com/wire/android/mediaplayer/$name").readText() -} diff --git a/core/media-player/build.gradle.kts b/core/video-player/build.gradle.kts similarity index 96% rename from core/media-player/build.gradle.kts rename to core/video-player/build.gradle.kts index d2455ea1ea7..ab9dcfc678b 100644 --- a/core/media-player/build.gradle.kts +++ b/core/video-player/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - namespace = "com.wire.android.mediaplayer" + namespace = "com.wire.android.videoplayer" } dependencies { diff --git a/core/video-player/lint-baseline.xml b/core/video-player/lint-baseline.xml new file mode 100644 index 00000000000..05a9be7dd95 --- /dev/null +++ b/core/video-player/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.kt b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlaybackState.kt similarity index 96% rename from core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.kt rename to core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlaybackState.kt index 8419a2b5931..644a207ff23 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlaybackState.kt +++ b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/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.mediaplayer +package com.wire.android.videoplayer data class VideoPlaybackState( val isPlaying: Boolean = false, diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayer.kt similarity index 99% rename from core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt rename to core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayer.kt index 5505390d1cc..8453121edd3 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayer.kt +++ b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayer.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.mediaplayer +package com.wire.android.videoplayer import android.app.Activity import android.content.Context @@ -104,7 +104,7 @@ 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. + * shared video-player Metro graph so any module can host this screen. */ @Composable fun VideoPlayer( diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModel.kt similarity index 96% rename from core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt rename to core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModel.kt index d72f5b5f3f3..e0fd7efe08f 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/VideoPlayerViewModel.kt +++ b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModel.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.mediaplayer +package com.wire.android.videoplayer import android.content.Context import android.net.Uri @@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.File +import kotlin.time.Duration.Companion.milliseconds /** * Plays a single video from either a local file ([localPath]) or a remote URL ([contentUrl]). @@ -45,7 +46,7 @@ import java.io.File * The screen arguments are passed in through the per-ViewModel assisted [Factory] * rather than read from a navigation destination, so the player can be reused from any module. */ -@WireAssistedViewModelBinding(MediaPlayerManualViewModelFactoryGroup::class) +@WireAssistedViewModelBinding(VideoPlayerManualViewModelFactoryGroup::class) class VideoPlayerViewModel @AssistedInject constructor( @ApplicationContext context: Context, @Assisted val localPath: String?, @@ -147,7 +148,7 @@ class VideoPlayerViewModel @AssistedInject constructor( durationMs = player.duration.coerceAtLeast(0).toInt(), ) } - delay(POSITION_POLL_MS) + delay(POSITION_POLL_MS.milliseconds) } } } diff --git a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModelGraph.kt similarity index 89% rename from core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt rename to core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModelGraph.kt index 6ca808e4984..586dc5ebafc 100644 --- a/core/media-player/src/main/kotlin/com/wire/android/mediaplayer/MediaPlayerViewModelGraph.kt +++ b/core/video-player/src/main/kotlin/com/wire/android/videoplayer/VideoPlayerViewModelGraph.kt @@ -17,14 +17,14 @@ */ @file:Suppress("MatchingDeclarationName") -package com.wire.android.mediaplayer +package com.wire.android.videoplayer import androidx.compose.runtime.Composable import com.wire.android.di.metro.wireAssistedMetroViewModel import com.wire.android.di.metro.WireAssistedViewModelFactoryGroup @WireAssistedViewModelFactoryGroup -object MediaPlayerManualViewModelFactoryGroup +object VideoPlayerManualViewModelFactoryGroup @Composable fun videoPlayerViewModel( @@ -32,7 +32,7 @@ fun videoPlayerViewModel( contentUrl: String?, fileName: String?, ): VideoPlayerViewModel = - wireAssistedMetroViewModel( + wireAssistedMetroViewModel( instanceKey = "video_player_${localPath ?: contentUrl}" ) { videoPlayerViewModel(localPath, contentUrl, fileName) diff --git a/core/media-player/src/main/res/drawable/ic_fullscreen.xml b/core/video-player/src/main/res/drawable/ic_fullscreen.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_fullscreen.xml rename to core/video-player/src/main/res/drawable/ic_fullscreen.xml diff --git a/core/media-player/src/main/res/drawable/ic_fullscreen_exit.xml b/core/video-player/src/main/res/drawable/ic_fullscreen_exit.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_fullscreen_exit.xml rename to core/video-player/src/main/res/drawable/ic_fullscreen_exit.xml diff --git a/features/cells/src/main/res/drawable/ic_pause.xml b/core/video-player/src/main/res/drawable/ic_pause.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_pause.xml rename to core/video-player/src/main/res/drawable/ic_pause.xml diff --git a/features/cells/src/main/res/drawable/ic_play.xml b/core/video-player/src/main/res/drawable/ic_play.xml similarity index 100% rename from features/cells/src/main/res/drawable/ic_play.xml rename to core/video-player/src/main/res/drawable/ic_play.xml diff --git a/core/media-player/src/main/res/drawable/ic_replay.xml b/core/video-player/src/main/res/drawable/ic_replay.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_replay.xml rename to core/video-player/src/main/res/drawable/ic_replay.xml diff --git a/core/media-player/src/main/res/drawable/ic_volume_off.xml b/core/video-player/src/main/res/drawable/ic_volume_off.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_volume_off.xml rename to core/video-player/src/main/res/drawable/ic_volume_off.xml diff --git a/core/media-player/src/main/res/drawable/ic_volume_on.xml b/core/video-player/src/main/res/drawable/ic_volume_on.xml similarity index 100% rename from core/media-player/src/main/res/drawable/ic_volume_on.xml rename to core/video-player/src/main/res/drawable/ic_volume_on.xml diff --git a/core/media-player/src/main/res/values-ar/strings.xml b/core/video-player/src/main/res/values-ar/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-ar/strings.xml rename to core/video-player/src/main/res/values-ar/strings.xml diff --git a/core/media-player/src/main/res/values-b+sr+Cyrl+ME/strings.xml b/core/video-player/src/main/res/values-b+sr+Cyrl+ME/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-b+sr+Cyrl+ME/strings.xml rename to core/video-player/src/main/res/values-b+sr+Cyrl+ME/strings.xml diff --git a/core/media-player/src/main/res/values-b+sr+Latn+ME/strings.xml b/core/video-player/src/main/res/values-b+sr+Latn+ME/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-b+sr+Latn+ME/strings.xml rename to core/video-player/src/main/res/values-b+sr+Latn+ME/strings.xml diff --git a/core/media-player/src/main/res/values-b+sr+Latn/strings.xml b/core/video-player/src/main/res/values-b+sr+Latn/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-b+sr+Latn/strings.xml rename to core/video-player/src/main/res/values-b+sr+Latn/strings.xml diff --git a/core/media-player/src/main/res/values-bg/strings.xml b/core/video-player/src/main/res/values-bg/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-bg/strings.xml rename to core/video-player/src/main/res/values-bg/strings.xml diff --git a/core/media-player/src/main/res/values-bs/strings.xml b/core/video-player/src/main/res/values-bs/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-bs/strings.xml rename to core/video-player/src/main/res/values-bs/strings.xml diff --git a/core/media-player/src/main/res/values-cs/strings.xml b/core/video-player/src/main/res/values-cs/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-cs/strings.xml rename to core/video-player/src/main/res/values-cs/strings.xml diff --git a/core/media-player/src/main/res/values-da/strings.xml b/core/video-player/src/main/res/values-da/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-da/strings.xml rename to core/video-player/src/main/res/values-da/strings.xml diff --git a/core/media-player/src/main/res/values-de/strings.xml b/core/video-player/src/main/res/values-de/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-de/strings.xml rename to core/video-player/src/main/res/values-de/strings.xml diff --git a/core/media-player/src/main/res/values-el/strings.xml b/core/video-player/src/main/res/values-el/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-el/strings.xml rename to core/video-player/src/main/res/values-el/strings.xml diff --git a/core/media-player/src/main/res/values-es/strings.xml b/core/video-player/src/main/res/values-es/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-es/strings.xml rename to core/video-player/src/main/res/values-es/strings.xml diff --git a/core/media-player/src/main/res/values-et/strings.xml b/core/video-player/src/main/res/values-et/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-et/strings.xml rename to core/video-player/src/main/res/values-et/strings.xml diff --git a/core/media-player/src/main/res/values-fi/strings.xml b/core/video-player/src/main/res/values-fi/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-fi/strings.xml rename to core/video-player/src/main/res/values-fi/strings.xml diff --git a/core/media-player/src/main/res/values-fr/strings.xml b/core/video-player/src/main/res/values-fr/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-fr/strings.xml rename to core/video-player/src/main/res/values-fr/strings.xml diff --git a/core/media-player/src/main/res/values-hr/strings.xml b/core/video-player/src/main/res/values-hr/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-hr/strings.xml rename to core/video-player/src/main/res/values-hr/strings.xml diff --git a/core/media-player/src/main/res/values-hu/strings.xml b/core/video-player/src/main/res/values-hu/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-hu/strings.xml rename to core/video-player/src/main/res/values-hu/strings.xml diff --git a/core/media-player/src/main/res/values-it/strings.xml b/core/video-player/src/main/res/values-it/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-it/strings.xml rename to core/video-player/src/main/res/values-it/strings.xml diff --git a/core/media-player/src/main/res/values-ja/strings.xml b/core/video-player/src/main/res/values-ja/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-ja/strings.xml rename to core/video-player/src/main/res/values-ja/strings.xml diff --git a/core/media-player/src/main/res/values-lb/strings.xml b/core/video-player/src/main/res/values-lb/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-lb/strings.xml rename to core/video-player/src/main/res/values-lb/strings.xml diff --git a/core/media-player/src/main/res/values-lt/strings.xml b/core/video-player/src/main/res/values-lt/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-lt/strings.xml rename to core/video-player/src/main/res/values-lt/strings.xml diff --git a/core/media-player/src/main/res/values-lv/strings.xml b/core/video-player/src/main/res/values-lv/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-lv/strings.xml rename to core/video-player/src/main/res/values-lv/strings.xml diff --git a/core/media-player/src/main/res/values-mk/strings.xml b/core/video-player/src/main/res/values-mk/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-mk/strings.xml rename to core/video-player/src/main/res/values-mk/strings.xml diff --git a/core/media-player/src/main/res/values-mt/strings.xml b/core/video-player/src/main/res/values-mt/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-mt/strings.xml rename to core/video-player/src/main/res/values-mt/strings.xml diff --git a/core/media-player/src/main/res/values-nl/strings.xml b/core/video-player/src/main/res/values-nl/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-nl/strings.xml rename to core/video-player/src/main/res/values-nl/strings.xml diff --git a/core/media-player/src/main/res/values-pl/strings.xml b/core/video-player/src/main/res/values-pl/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-pl/strings.xml rename to core/video-player/src/main/res/values-pl/strings.xml diff --git a/core/media-player/src/main/res/values-pt/strings.xml b/core/video-player/src/main/res/values-pt/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-pt/strings.xml rename to core/video-player/src/main/res/values-pt/strings.xml diff --git a/core/media-player/src/main/res/values-ro/strings.xml b/core/video-player/src/main/res/values-ro/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-ro/strings.xml rename to core/video-player/src/main/res/values-ro/strings.xml diff --git a/core/media-player/src/main/res/values-ru/strings.xml b/core/video-player/src/main/res/values-ru/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-ru/strings.xml rename to core/video-player/src/main/res/values-ru/strings.xml diff --git a/core/media-player/src/main/res/values-si/strings.xml b/core/video-player/src/main/res/values-si/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-si/strings.xml rename to core/video-player/src/main/res/values-si/strings.xml diff --git a/core/media-player/src/main/res/values-sk/strings.xml b/core/video-player/src/main/res/values-sk/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-sk/strings.xml rename to core/video-player/src/main/res/values-sk/strings.xml diff --git a/core/media-player/src/main/res/values-sl/strings.xml b/core/video-player/src/main/res/values-sl/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-sl/strings.xml rename to core/video-player/src/main/res/values-sl/strings.xml diff --git a/core/media-player/src/main/res/values-sq/strings.xml b/core/video-player/src/main/res/values-sq/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-sq/strings.xml rename to core/video-player/src/main/res/values-sq/strings.xml diff --git a/core/media-player/src/main/res/values-sr/strings.xml b/core/video-player/src/main/res/values-sr/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-sr/strings.xml rename to core/video-player/src/main/res/values-sr/strings.xml diff --git a/core/media-player/src/main/res/values-sv/strings.xml b/core/video-player/src/main/res/values-sv/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-sv/strings.xml rename to core/video-player/src/main/res/values-sv/strings.xml diff --git a/core/media-player/src/main/res/values-tr/strings.xml b/core/video-player/src/main/res/values-tr/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-tr/strings.xml rename to core/video-player/src/main/res/values-tr/strings.xml diff --git a/core/media-player/src/main/res/values-uk/strings.xml b/core/video-player/src/main/res/values-uk/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-uk/strings.xml rename to core/video-player/src/main/res/values-uk/strings.xml diff --git a/core/media-player/src/main/res/values-zh/strings.xml b/core/video-player/src/main/res/values-zh/strings.xml similarity index 100% rename from core/media-player/src/main/res/values-zh/strings.xml rename to core/video-player/src/main/res/values-zh/strings.xml diff --git a/core/media-player/src/main/res/values/strings.xml b/core/video-player/src/main/res/values/strings.xml similarity index 100% rename from core/media-player/src/main/res/values/strings.xml rename to core/video-player/src/main/res/values/strings.xml diff --git a/crowdin.yml b/crowdin.yml index 60b5730b396..d1cb42a24f6 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/audio-player/src/main/res/values/strings.xml", + "translation": "/core/audio-player/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%" @@ -23,6 +27,10 @@ files: [ "source": "/core/ui-common/src/main/res/values/strings.xml", "translation": "/core/ui-common/src/main/res/values-%two_letters_code%/%original_file_name%" }, + { + "source": "/core/video-player/src/main/res/values/strings.xml", + "translation": "/core/video-player/src/main/res/values-%two_letters_code%/%original_file_name%" + }, { "source": "/features/cells/src/main/res/values/strings.xml", "translation": "/features/cells/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 index 0806d071e88..a19a645da67 100644 --- a/docs/adr/0013-shared-video-player-core-module.md +++ b/docs/adr/0013-shared-video-player-core-module.md @@ -34,7 +34,7 @@ is a `core` module. ## Decision -Extract the video player into a new **`core:media-player`** module and consume it from both +Extract the video player into a new **`core:video-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 @@ -48,13 +48,13 @@ The two pieces that are currently cells-specific will be generalized during the - **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` +- **Dependency injection:** move the Metro wiring out of `CellsViewModelFactory` so `core:video-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 +1. Create `core:video-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. @@ -63,11 +63,11 @@ caller before chat depends on it: ## Consequences -- A new module `core:media-player` is added; it is auto-discovered by `settings.gradle.kts` and built +- A new module `core:video-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` +- `features:cells` and `app` both depend on `core:video-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. @@ -76,5 +76,5 @@ caller before chat depends on it: 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 + and the app `MediaGalleryScreen` overlap; `core:video-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 703ff8e6b92..38d517a4f4a 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -16,7 +16,8 @@ dependencies { implementation(project(":core:di")) implementation(project(":core:navigation")) implementation(project(":core:ui-common")) - implementation(project(":core:media-player")) + implementation(project(":core:video-player")) + implementation(project(":core:audio-player")) implementation(libs.compose.activity) implementation(libs.androidx.core) implementation(libs.androidx.appcompat) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt index fcf99164090..6bbd41f5395 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/navigation/CellsNavigation3Renderer.kt @@ -20,44 +20,41 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import com.wire.android.audioplayer.AudioPlayer import com.wire.android.feature.cells.ui.CellsFilesNavigation import com.wire.android.feature.cells.ui.ConversationFilesRouteScreen import com.wire.android.feature.cells.ui.ConversationFilesSlideRouteScreen import com.wire.android.feature.cells.ui.addRemoveTagsViewModel -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs -import com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerRouteScreen -import com.wire.android.feature.cells.ui.cellAudioPlayerViewModel import com.wire.android.feature.cells.ui.cellViewModel import com.wire.android.feature.cells.ui.create.file.CreateFileRouteScreen import com.wire.android.feature.cells.ui.create.file.FileType -import com.wire.android.feature.cells.ui.createFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderRouteScreen +import com.wire.android.feature.cells.ui.createFileViewModel import com.wire.android.feature.cells.ui.createFolderViewModel import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerScreenContent -import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.moveToFolderViewModel -import com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen -import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData +import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderRouteScreen import com.wire.android.feature.cells.ui.publicLinkExpirationViewModel import com.wire.android.feature.cells.ui.publicLinkPasswordViewModel import com.wire.android.feature.cells.ui.publicLinkViewModel +import com.wire.android.feature.cells.ui.publiclink.PublicLinkRouteScreen +import com.wire.android.feature.cells.ui.publiclink.PublicLinkScreenData import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationRouteScreen import com.wire.android.feature.cells.ui.publiclink.settings.password.PublicLinkPasswordRouteScreen import com.wire.android.feature.cells.ui.recyclebin.RecycleBinRouteScreen import com.wire.android.feature.cells.ui.rename.RenameNodeRouteScreen import com.wire.android.feature.cells.ui.renameNodeViewModel import com.wire.android.feature.cells.ui.search.SearchRouteScreen +import com.wire.android.feature.cells.ui.search.sort.SortCriteriaNavArg import com.wire.android.feature.cells.ui.searchCellViewModel import com.wire.android.feature.cells.ui.searchScreenViewModel import com.wire.android.feature.cells.ui.tags.AddRemoveTagsRouteScreen import com.wire.android.feature.cells.ui.versionHistoryViewModel import com.wire.android.feature.cells.ui.versioning.VersionHistoryRouteScreen -import com.wire.android.mediaplayer.VideoPlayer import com.wire.android.navigation.navigation3.WireNavigation3ResultType import com.wire.android.navigation.navigation3.WireNavigation3Runtime -import androidx.compose.ui.platform.LocalContext -import com.wire.android.feature.cells.ui.search.sort.SortCriteriaNavArg +import com.wire.android.videoplayer.VideoPlayer import com.wire.navigation.WireBackStackMode import com.wire.navigation.WireNavResult import com.wire.navigation.WireNavResultRequestId @@ -92,6 +89,7 @@ internal fun CellsNavigation3RouteScreen( viewModel = cellViewModel(route.args.toScreenArgs()), ) } + is ConversationFilesSlideRoute -> AnimatedVisibility(visible = true) { ConversationFilesSlideRouteScreen( navigation = filesNavigation, @@ -100,10 +98,12 @@ internal fun CellsNavigation3RouteScreen( viewModel = cellViewModel(route.args.toScreenArgs()), ) } + is RecycleBinRoute -> RecycleBinRouteScreen( navigation = filesNavigation, cellViewModel = cellViewModel(route.args.toScreenArgs()), ) + is CreateFolderRoute -> CreateFolderRouteScreen( onNavigateBack = navigateBack, onCreated = { @@ -111,6 +111,7 @@ internal fun CellsNavigation3RouteScreen( }, createFolderViewModel = createFolderViewModel(route.toScreenArgs()), ) + is CreateFileRoute -> CreateFileRouteScreen( onNavigateBack = navigateBack, onCreated = { @@ -118,6 +119,7 @@ internal fun CellsNavigation3RouteScreen( }, createFileViewModel = createFileViewModel(route.toScreenArgs()), ) + is MoveToFolderRoute -> { val viewModel = moveToFolderViewModel(route.toScreenArgs()) ConsumeNavigation3Result( @@ -161,6 +163,7 @@ internal fun CellsNavigation3RouteScreen( moveToFolderViewModel = viewModel, ) } + is PublicLinkRoute -> { val viewModel = publicLinkViewModel(route.toScreenArgs()) ConsumeNavigation3Result( @@ -194,6 +197,7 @@ internal fun CellsNavigation3RouteScreen( viewModel = viewModel, ) } + is PublicLinkExpirationRoute -> PublicLinkExpirationRouteScreen( onResult = { result -> if (!runtime.completeCurrentAndPop( @@ -206,22 +210,27 @@ internal fun CellsNavigation3RouteScreen( }, viewModel = publicLinkExpirationViewModel(route.toScreenArgs()), ) + is PublicLinkPasswordRoute -> PublicLinkPasswordRouteScreen( onResult = { completeBooleanResult(runtime, it) }, viewModel = publicLinkPasswordViewModel(route.toScreenArgs()), ) + is RenameNodeRoute -> RenameNodeRouteScreen( onNavigateBack = navigateBack, renameNodeViewModel = renameNodeViewModel(route.toScreenArgs()), ) + is AddRemoveTagsRoute -> AddRemoveTagsRouteScreen( onNavigateBack = navigateBack, addRemoveTagsViewModel = addRemoveTagsViewModel(route.toScreenArgs()), ) + is VersionHistoryRoute -> VersionHistoryRouteScreen( onNavigateBack = navigateBack, versionHistoryViewModel = versionHistoryViewModel(route.toScreenArgs()), ) + is CellImageViewerRoute -> CellImageViewerScreenContent( localPath = route.localPath, contentUrl = route.contentUrl, @@ -230,19 +239,21 @@ internal fun CellsNavigation3RouteScreen( fileName = route.fileName, onNavigateBack = navigateBack, ) + is VideoPlayerRoute -> VideoPlayer( localPath = route.localPath, contentUrl = route.contentUrl, fileName = route.fileName, onNavigateBack = navigateBack, ) - is AudioPlayerRoute -> CellAudioPlayerRouteScreen( + + is AudioPlayerRoute -> AudioPlayer( + localPath = route.localPath, + contentUrl = route.contentUrl, + fileName = route.fileName, onNavigateBack = navigateBack, - viewModel = cellAudioPlayerViewModel( - LocalContext.current, - AudioPlayerNavArgs(route.localPath, route.contentUrl, route.fileName), - ), ) + is SearchRoute -> AnimatedVisibility(visible = true) { SearchRouteScreen( navigation = filesNavigation, 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 a0fcdb43333..2283d0e790d 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 @@ -20,8 +20,6 @@ package com.wire.android.feature.cells.ui -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel @@ -64,7 +62,6 @@ internal interface CellsManualViewModelFactory : ManualViewModelAssistedFactory fun addRemoveTags(navArgs: AddRemoveTagsNavArgs): AddRemoveTagsViewModel fun versionHistory(navArgs: VersionHistoryNavArgs): VersionHistoryViewModel fun imageViewer(navArgs: CellImageViewerNavArgs): CellImageViewerViewModel - fun audioPlayer(context: android.content.Context, navArgs: AudioPlayerNavArgs): AudioPlayerViewModel } @BindingContainer @@ -87,7 +84,6 @@ object CellsMetroViewModelBindings { addRemoveTagsFactory: AddRemoveTagsViewModel.Factory, versionHistoryFactory: VersionHistoryViewModel.Factory, imageViewerFactory: CellImageViewerViewModel.Factory, - audioPlayerFactory: AudioPlayerViewModel.Factory, ): ManualViewModelAssistedFactory = object : CellsManualViewModelFactory { override fun cell(navArgs: CellFilesNavArgs) = cellFactory.create(navArgs, null) @@ -105,7 +101,5 @@ object CellsMetroViewModelBindings { override fun addRemoveTags(navArgs: AddRemoveTagsNavArgs) = addRemoveTagsFactory.create(navArgs) override fun versionHistory(navArgs: VersionHistoryNavArgs) = versionHistoryFactory.create(navArgs) override fun imageViewer(navArgs: CellImageViewerNavArgs) = imageViewerFactory.create(navArgs) - override fun audioPlayer(context: android.content.Context, navArgs: AudioPlayerNavArgs) = - audioPlayerFactory.create(context, navArgs.localPath, navArgs.contentUrl, navArgs.fileName) } } 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 0f6f9076d4d..fe8e94e2239 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellsViewModelGraph.kt @@ -22,8 +22,6 @@ package com.wire.android.feature.cells.ui import androidx.compose.runtime.Composable import com.wire.android.di.metro.wireAssistedMetroViewModel -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel -import com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel @@ -110,12 +108,3 @@ internal fun versionHistoryViewModel(navArgs: VersionHistoryNavArgs): VersionHis @Composable internal fun cellImageViewerViewModel(navArgs: CellImageViewerNavArgs): CellImageViewerViewModel = wireAssistedMetroViewModel { imageViewer(navArgs) } - -@Composable -internal fun cellAudioPlayerViewModel( - context: android.content.Context, - navArgs: AudioPlayerNavArgs, -): AudioPlayerViewModel = - wireAssistedMetroViewModel { - audioPlayer(context, navArgs) - } diff --git a/features/cells/stability/cells-debug.stability b/features/cells/stability/cells-debug.stability index a03df7b29cb..f658845f228 100644 --- a/features/cells/stability/cells-debug.stability +++ b/features/cells/stability/cells-debug.stability @@ -256,43 +256,6 @@ internal fun com.wire.android.feature.cells.ui.addRemoveTagsViewModel(navArgs: c params: - navArgs: UNSTABLE (has mutable properties or unstable members) -@Composable -internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerContent(state: com.wire.android.feature.cells.ui.audioplayer.AudioPlaybackState, fileName: kotlin.String?, onTogglePlayPause: kotlin.Function0, onSeek: kotlin.Function1, onStop: kotlin.Function0, onNavigateBack: kotlin.Function0, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: true - restartable: true - params: - - state: STABLE (class with no mutable properties) - - fileName: STABLE (class with no mutable properties) - - onTogglePlayPause: STABLE (function type) - - onSeek: STABLE (function type) - - onStop: STABLE (function type) - - onNavigateBack: STABLE (function type) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -internal fun com.wire.android.feature.cells.ui.audioplayer.CellAudioPlayerRouteScreen(onNavigateBack: kotlin.Function0, viewModel: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel, modifier: androidx.compose.ui.Modifier): kotlin.Unit - skippable: false - restartable: true - params: - - onNavigateBack: STABLE (function type) - - viewModel: UNSTABLE (has mutable properties or unstable members) - - modifier: STABLE (marked @Stable or @Immutable) - -@Composable -private fun com.wire.android.feature.cells.ui.audioplayer.EqualizerBars(isPlaying: kotlin.Boolean): kotlin.Unit - skippable: true - restartable: true - params: - - isPlaying: STABLE (primitive type) - -@Composable -internal fun com.wire.android.feature.cells.ui.cellAudioPlayerViewModel(context: android.content.Context, navArgs: com.wire.android.feature.cells.ui.audioplayer.AudioPlayerNavArgs): com.wire.android.feature.cells.ui.audioplayer.AudioPlayerViewModel - skippable: false - restartable: true - params: - - context: RUNTIME (requires runtime check) - - navArgs: STABLE (class with no mutable properties) - @Composable internal fun com.wire.android.feature.cells.ui.cellImageViewerViewModel(navArgs: com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs): com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel skippable: true diff --git a/features/meetings/stability/meetings-debug.stability b/features/meetings/stability/meetings-debug.stability index 70ba94ebac6..9bf3d6b13e6 100644 --- a/features/meetings/stability/meetings-debug.stability +++ b/features/meetings/stability/meetings-debug.stability @@ -316,6 +316,12 @@ private fun com.wire.android.feature.meetings.ui.list.getDateHeaderString(time: params: - time: STABLE (matched by stability configuration) +@Composable +private fun com.wire.android.feature.meetings.ui.list.isLoading(): kotlin.Boolean + skippable: true + restartable: true + params: + @Composable public fun com.wire.android.feature.meetings.ui.meetingListViewModel(type: com.wire.android.feature.meetings.ui.MeetingsTabItem): com.wire.android.feature.meetings.ui.list.MeetingListViewModel skippable: true diff --git a/kalium b/kalium index ddcdaba4c10..73efbba06fd 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ddcdaba4c1034080f65eef9456cee1a23e512dac +Subproject commit 73efbba06fddb23b5990bc7c17c28c3d093020ce