diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0672e760fc..ba8c0504401 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -215,11 +215,6 @@ android:taskAffinity=".call" android:theme="@style/AppTheme.CallLauncher" /> - - , val playbackSpeed: PlaybackSpeed = PlaybackSpeed.NORMAL ) : MessageTypeContent + + data class AudioFile( + val fileName: String, + val isPlaying: Boolean, + val isDownloading: Boolean, + val durationSeconds: Int, + val playedSeconds: Int, + val seekbarProgress: Int + ) : MessageTypeContent } enum class MessageStatusIcon { @@ -269,6 +279,8 @@ fun getMessageTypeContent(user: User, message: ChatMessage, isClassified: Boolea getVoiceContent(message) } else if (message.hasFileAttachment && message.isTemporary) { getUploadingMediaContent(message) + } else if (message.hasFileAttachment && message.fileParameters.mimetype.startsWith(Mimetype.AUDIO_PREFIX)) { + getAudioFileContent(message) } else if (message.hasFileAttachment) { getMediaContent(user, message, isClassified) } else if (message.hasGeoLocation) { @@ -392,3 +404,13 @@ fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = seekbarProgress = message.voiceMessageSeekbarProgress, waveform = message.voiceMessageFloatArray?.toList().orEmpty() ) + +fun getAudioFileContent(message: ChatMessage): MessageTypeContent.AudioFile = + MessageTypeContent.AudioFile( + fileName = message.fileParameters.name, + isPlaying = message.isPlayingVoiceMessage, + isDownloading = message.isDownloadingVoiceMessage, + durationSeconds = message.voiceMessageDuration, + playedSeconds = message.voiceMessagePlayedSeconds, + seekbarProgress = message.voiceMessageSeekbarProgress + ) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 30ab6b1e4ca..0643e27542b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -1095,22 +1095,34 @@ class ChatViewModel @AssistedInject constructor( _uiState.update { current -> val updatedItems = current.items.map { item -> if (item is ChatItem.MessageItem && item.uiMessage.id == message.jsonMessageId) { - val voiceContent = item.uiMessage.content as? MessageTypeContent.Voice - if (voiceContent != null) { - val updatedVoiceContent = voiceContent.copy( - actorId = message.actorId, - isPlaying = message.isPlayingVoiceMessage, - wasPlayed = message.wasPlayedVoiceMessage, - isDownloading = message.isDownloadingVoiceMessage, - durationSeconds = message.voiceMessageDuration, - playedSeconds = message.voiceMessagePlayedSeconds, - seekbarProgress = message.voiceMessageSeekbarProgress, - waveform = message.voiceMessageFloatArray?.toList() ?: voiceContent.waveform - // playbackSpeed is preserved from existing voiceContent - ) - item.copy(uiMessage = item.uiMessage.copy(content = updatedVoiceContent)) - } else { - item + when (val content = item.uiMessage.content) { + is MessageTypeContent.Voice -> { + val updatedVoiceContent = content.copy( + actorId = message.actorId, + isPlaying = message.isPlayingVoiceMessage, + wasPlayed = message.wasPlayedVoiceMessage, + isDownloading = message.isDownloadingVoiceMessage, + durationSeconds = message.voiceMessageDuration, + playedSeconds = message.voiceMessagePlayedSeconds, + seekbarProgress = message.voiceMessageSeekbarProgress, + waveform = message.voiceMessageFloatArray?.toList() ?: content.waveform + // playbackSpeed is preserved from existing content + ) + item.copy(uiMessage = item.uiMessage.copy(content = updatedVoiceContent)) + } + + is MessageTypeContent.AudioFile -> { + val updatedAudioFileContent = content.copy( + isPlaying = message.isPlayingVoiceMessage, + isDownloading = message.isDownloadingVoiceMessage, + durationSeconds = message.voiceMessageDuration, + playedSeconds = message.voiceMessagePlayedSeconds, + seekbarProgress = message.voiceMessageSeekbarProgress + ) + item.copy(uiMessage = item.uiMessage.copy(content = updatedAudioFileContent)) + } + + else -> item } } else { item diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt deleted file mode 100644 index bdaa2c23b61..00000000000 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-FileCopyrightText: 2026 Enrique López-Mañas - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.fullscreenfile - -import android.content.Intent -import android.os.Bundle -import android.util.Log -import android.view.WindowManager -import android.widget.FrameLayout -import androidx.activity.SystemBarStyle -import androidx.activity.enableEdgeToEdge -import androidx.annotation.OptIn -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.content.FileProvider -import androidx.core.net.toUri -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.fragment.app.DialogFragment -import androidx.media3.common.AudioAttributes -import androidx.media3.common.MediaItem -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import autodagger.AutoInjector -import com.nextcloud.talk.BuildConfig -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.ui.SwipeToCloseLayout -import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC -import java.io.File -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class FullScreenMediaActivity : AppCompatActivity() { - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var path: String - private lateinit var fileName: String - private lateinit var mediaFile: File - private var player: ExoPlayer? by mutableStateOf(null) - private var playWhenReadyState: Boolean = true - private var playBackPosition: Long = 0L - private lateinit var windowInsetsController: WindowInsetsControllerCompat - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - fileName = intent.getStringExtra("FILE_NAME").orEmpty() - val isAudioOnly = intent.getBooleanExtra("AUDIO_ONLY", false) - mediaFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { - Log.e(TAG, "Invalid media filename: $fileName") - finish() - return - } - path = mediaFile.absolutePath - - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) - ) - initWindowInsetsController() - - val swipeToCloseLayout = SwipeToCloseLayout(this) - swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { - override fun onSwipeToClose() { - finish() - } - }) - - val composeView = ComposeView(this).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = viewThemeUtils.getColorScheme(this@FullScreenMediaActivity) - MaterialTheme(colorScheme = colorScheme) { - FullScreenMediaScreen( - title = fileName, - player = player, - isAudioOnly = isAudioOnly, - actions = FullScreenMediaActions( - onShare = { shareFile() }, - onSave = { showSaveDialog() }, - onEnterImmersive = { enterImmersiveMode() }, - onExitImmersive = { exitImmersiveMode() } - ) - ) - } - } - } - - swipeToCloseLayout.addView( - composeView, - FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - ) - setContentView(swipeToCloseLayout) - - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - - override fun onStart() { - super.onStart() - initializePlayer() - preparePlayer() - } - - override fun onStop() { - super.onStop() - releasePlayer() - } - - @OptIn(UnstableApi::class) - private fun initializePlayer() { - player = ExoPlayer.Builder(applicationContext) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .build() - } - - private fun preparePlayer() { - val mediaItem: MediaItem = MediaItem.fromUri(mediaFile.toUri()) - player?.let { exoPlayer -> - exoPlayer.setMediaItem(mediaItem) - exoPlayer.playWhenReady = playWhenReadyState - exoPlayer.seekTo(playBackPosition) - exoPlayer.prepare() - } - } - - private fun releasePlayer() { - player?.let { exoPlayer -> - playBackPosition = exoPlayer.currentPosition - playWhenReadyState = exoPlayer.playWhenReady - exoPlayer.release() - } - player = null - } - - private fun initWindowInsetsController() { - windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - - private fun enterImmersiveMode() { - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - } - - private fun exitImmersiveMode() { - windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - } - - private fun shareFile() { - val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, mediaFile) - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = VIDEO_PREFIX_GENERIC - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) - } - - private fun showSaveDialog() { - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) - saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) - } - - companion object { - private val TAG = FullScreenMediaActivity::class.java.simpleName - } -} diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt deleted file mode 100644 index e5ce478b073..00000000000 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-FileCopyrightText: 2023 Parneet Singh - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2026 Enrique López-Mañas - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package com.nextcloud.talk.fullscreenfile - -import android.content.res.Configuration -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.widget.FrameLayout -import androidx.annotation.OptIn -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBarColors -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.DefaultTimeBar -import androidx.media3.ui.PlayerView -import com.nextcloud.talk.R -import com.nextcloud.talk.components.StandardAppBar - -private const val TOOLBAR_ALPHA = 0.5f - -@OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) -@Composable -fun FullScreenMediaScreen(title: String, player: ExoPlayer?, isAudioOnly: Boolean, actions: FullScreenMediaActions) { - val toolbarColors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White - ) - - var showToolbar by remember { mutableStateOf(true) } - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - MediaPlayerView( - player = player, - isAudioOnly = isAudioOnly, - onControllerVisible = { - showToolbar = true - actions.onExitImmersive() - }, - onControllerHidden = { - showToolbar = false - actions.onEnterImmersive() - } - ) - - BottomGradient(modifier = Modifier.align(Alignment.BottomCenter)) - - if (showToolbar) { - ToolbarOverlay(title = title, toolbarColors = toolbarColors, actions = actions) - } - } -} - -@OptIn(UnstableApi::class) -@Composable -private fun MediaPlayerView( - player: ExoPlayer?, - isAudioOnly: Boolean, - onControllerVisible: () -> Unit, - onControllerHidden: () -> Unit -) { - if (LocalInspectionMode.current) { - Box(modifier = Modifier.fillMaxSize()) - return - } - - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current - val bottomPx = WindowInsets.systemBars.getBottom(density) - val leftPx = WindowInsets.systemBars.getLeft(density, layoutDirection) - val rightPx = WindowInsets.systemBars.getRight(density, layoutDirection) - val originalProgressMarginBottom = remember { intArrayOf(-1) } - - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - showController() - if (isAudioOnly) { - controllerShowTimeoutMs = 0 - } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visibility -> - if (visibility == View.VISIBLE) onControllerVisible() else onControllerHidden() - } - ) - } - }, - update = { playerView -> - playerView.player = player - val exoControls = playerView.findViewById(R.id.exo_bottom_bar) - val exoProgress = playerView.findViewById(R.id.exo_progress) - exoControls?.apply { - updateLayoutParams { bottomMargin = bottomPx } - updatePadding(left = leftPx, right = rightPx) - } - exoProgress?.apply { - if (originalProgressMarginBottom[0] < 0) { - originalProgressMarginBottom[0] = - (layoutParams as? MarginLayoutParams)?.bottomMargin ?: 0 - } - updateLayoutParams { - bottomMargin = bottomPx + originalProgressMarginBottom[0] - } - updatePadding(left = leftPx, right = rightPx) - } - }, - modifier = Modifier.fillMaxSize() - ) -} - -@Composable -private fun BottomGradient(modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .windowInsetsBottomHeight(WindowInsets.navigationBars) - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = TOOLBAR_ALPHA)) - ) - ) - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ToolbarOverlay(title: String, toolbarColors: TopAppBarColors, actions: FullScreenMediaActions) { - val menuItems = buildList { - add(stringResource(R.string.share) to actions.onShare) - add(stringResource(R.string.nc_save_message) to actions.onSave) - } - Box { - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - colors = listOf(Color.Black.copy(alpha = TOOLBAR_ALPHA), Color.Transparent) - ) - ) - ) - StandardAppBar(title = title, menuItems = menuItems, colors = toolbarColors) - } -} - -data class FullScreenMediaActions( - val onShare: () -> Unit, - val onSave: () -> Unit, - val onEnterImmersive: () -> Unit, - val onExitImmersive: () -> Unit -) - -@Preview(name = "Light", showBackground = true) -@Composable -private fun PreviewFullScreenMediaLight() { - MaterialTheme(colorScheme = lightColorScheme()) { - FullScreenMediaScreen( - title = "video.mp4", - player = null, - isAudioOnly = false, - actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) - ) - } -} - -@Preview(name = "Dark - RTL Arabic", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "ar") -@Composable -private fun PreviewFullScreenMediaDarkRtlArabic() { - MaterialTheme(colorScheme = darkColorScheme()) { - FullScreenMediaScreen( - title = "فيديو.mp4", - player = null, - isAudioOnly = false, - actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) - ) - } -} diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 8f851d414b4..9cab0a0dd00 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -76,12 +76,6 @@ class MediaViewerActivity : BaseActivity() { windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - // Deliberately no SwipeToCloseLayout here (unlike FullScreenMediaActivity, still used for - // audio): its ViewDragHelper intercepts drags at the parent level before - // the HorizontalPager below ever sees them, and a real swipe is rarely perfectly - // horizontal - the small vertical component was enough to trigger it, closing the viewer - // on what the user meant as a page-navigation swipe. Closing is still available via the - // top bar's Close button and the system back gesture/button. val composeView = ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 04c3c55cfc2..60894e910af 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -31,7 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.systemBarsIgnoringVisibility import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -97,6 +98,7 @@ import pl.droidsonroids.gif.GifImageView private const val TOOLBAR_ALPHA = 0.6f private const val MAX_SCALE = 6.0f private const val MEDIUM_SCALE = 2.45f +private const val CONTROLLER_SHOW_TIMEOUT_MS = 2000 private val thumbnailSize = 48.dp private val thumbnailSpacing = 4.dp @@ -163,14 +165,13 @@ fun MediaViewerScreen( } // Tapping the currently shown item toggles this off, hiding the top bar and thumbnail strip so - // only the media itself is visible - mirrors FullScreenMediaScreen's own tap-to-toggle-fullscreen - // behavior (still used for audio). For video, ExoPlayer's own controller visibility is the + // only the media itself is visible. For video, ExoPlayer's own controller visibility is the // source of truth (see VideoPlayerView) rather than an independently toggled flag, since the // controller already auto-hides itself after a timeout. var showControls by remember { mutableStateOf(true) } // The status/nav bars toggle together with the top bar and thumbnail strip - one tap hides all - // of it, matching FullScreenMediaScreen's own tap-to-toggle-fullscreen. + // of it. LaunchedEffect(showControls) { onControlsVisibilityChanged(showControls) } @@ -385,11 +386,10 @@ private fun ImagePage(localPath: String, onToggleControls: () -> Unit) { // Pushes ExoPlayer's own controller (progress bar, play/pause row) up by extraBottomInsetPx (the // thumbnail strip's height, when one is showing for the current group) on top of the system nav -// bar inset, same technique FullScreenMediaScreen's MediaPlayerView already uses to keep the -// controller clear of the nav bar - so the controller never renders underneath the strip instead -// of shrinking the video content itself, which would visibly resize the video on every -// show/hide-controls tap. +// bar inset, so the controller never renders underneath the strip instead of shrinking the video +// content itself, which would visibly resize the video on every show/hide-controls tap. @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalLayoutApi::class) @Composable private fun VideoPlayerView( exoPlayer: ExoPlayer, @@ -398,9 +398,13 @@ private fun VideoPlayerView( ) { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current - val systemBarsBottomPx = WindowInsets.systemBars.getBottom(density) - val leftPx = WindowInsets.systemBars.getLeft(density, layoutDirection) - val rightPx = WindowInsets.systemBars.getRight(density, layoutDirection) + // Deliberately "IgnoringVisibility": the plain systemBars value animates across many frames + // while we hide/show the bars (see enterImmersiveMode/exitImmersiveMode), and reapplying + // these margins on every one of those frames was fighting the ExoPlayer controller's own + // hide animation, flipping it back to visible mid-fade and looping forever. + val systemBarsBottomPx = WindowInsets.systemBarsIgnoringVisibility.getBottom(density) + val leftPx = WindowInsets.systemBarsIgnoringVisibility.getLeft(density, layoutDirection) + val rightPx = WindowInsets.systemBarsIgnoringVisibility.getRight(density, layoutDirection) val bottomPx = systemBarsBottomPx + extraBottomInsetPx val originalProgressMarginBottom = remember { intArrayOf(-1) } @@ -409,6 +413,7 @@ private fun VideoPlayerView( PlayerView(ctx).apply { player = exoPlayer useController = true + controllerShowTimeoutMs = CONTROLLER_SHOW_TIMEOUT_MS showController() setControllerVisibilityListener( PlayerView.ControllerVisibilityListener { visibility -> diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index bfe26b12554..250c89203af 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -8,9 +8,11 @@ package com.nextcloud.talk.shareditems.adapters import android.content.Context +import android.content.Intent import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.SharedItemGridBinding import com.nextcloud.talk.databinding.SharedItemListBinding @@ -29,6 +31,7 @@ import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.bundle.BundleKeys import java.util.Collections.emptyList class SharedItemsAdapter( @@ -68,7 +71,7 @@ class SharedItemsAdapter( override fun onBindViewHolder(holder: SharedItemsViewHolder, position: Int) { when (val item = items[position]) { is SharedPollItem -> holder.onBind(item, ::showPoll) - is SharedFileItem -> holder.onBind(item, ::openMediaViewer) + is SharedFileItem -> holder.onBind(item, ::openMediaViewer, ::openInChat) is SharedLocationItem -> holder.onBind(item) is SharedOtherItem -> holder.onBind(item) is SharedDeckCardItem -> holder.onBind(item) @@ -121,6 +124,14 @@ class SharedItemsAdapter( context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) } + private fun openInChat(item: SharedFileItem, context: Context) { + val intent = Intent(context, ChatActivity::class.java).apply { + putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) + putExtra(BundleKeys.KEY_MESSAGE_ID, item.messageId) + } + context.startActivity(intent) + } + private fun openMessage(item: SharedItem, context: Context) { val credentials = ApiUtils.getCredentials(user.username, user.token) val baseUrl = user.baseUrl diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt index b4e28cc3458..124a9471753 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt @@ -40,8 +40,12 @@ class SharedItemsListViewHolder( override val progressBar: ProgressBar get() = binding.progressBar - override fun onBind(item: SharedFileItem, openMediaViewer: (SharedFileItem, Context) -> Unit) { - super.onBind(item, openMediaViewer) + override fun onBind( + item: SharedFileItem, + openMediaViewer: (SharedFileItem, Context) -> Unit, + openInChat: (SharedFileItem, Context) -> Unit + ) { + super.onBind(item, openMediaViewer, openInChat) binding.fileName.text = item.name binding.fileSize.text = item.fileSize.let { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt index 10b64829946..647481f3fa0 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.shareditems.model.SharedPinnedItem import com.nextcloud.talk.shareditems.model.SharedPollItem import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.FileViewerUtils +import com.nextcloud.talk.utils.Mimetype abstract class SharedItemsViewHolder( open val binding: ViewBinding, @@ -41,7 +42,11 @@ abstract class SharedItemsViewHolder( abstract val clickTarget: View abstract val progressBar: ProgressBar - open fun onBind(item: SharedFileItem, openMediaViewer: (SharedFileItem, Context) -> Unit) { + open fun onBind( + item: SharedFileItem, + openMediaViewer: (SharedFileItem, Context) -> Unit, + openInChat: (SharedFileItem, Context) -> Unit + ) { val placeholder = viewThemeUtils.talk.getPlaceholderImage(image.context, item.mimeType) if (item.previewAvailable) { image.loadImage( @@ -60,6 +65,11 @@ abstract class SharedItemsViewHolder( return } + if (item.mimeType.startsWith(Mimetype.AUDIO_PREFIX)) { + clickTarget.setOnClickListener { openInChat(item, image.context) } + return + } + /* The FileViewerUtils forces us to do things at this points which should be done separated in the activity and the view model. diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt new file mode 100644 index 00000000000..2c85897e1bf --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt @@ -0,0 +1,161 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent + +private const val SEEKBAR_MAX = 100 +private const val INACTIVE_TRACK_ALPHA = 0.4f + +@OptIn(ExperimentalMaterial3Api::class) +@Suppress("Detekt.LongMethod") +@Composable +fun AudioFileMessage( + typeContent: MessageTypeContent.AudioFile, + message: ChatMessageUi, + isOneToOneConversation: Boolean = false, + conversationThreadId: Long? = null, + onPlayPauseClick: (Int) -> Unit = {}, + onSeek: (messageId: Int, progress: Int) -> Unit = { _, _ -> } +) { + MessageScaffold( + uiMessage = message, + isOneToOneConversation = isOneToOneConversation, + conversationThreadId = conversationThreadId, + forceTimeBelow = true, + content = { + val remainingSeconds = (typeContent.durationSeconds - typeContent.playedSeconds) + val icon = if (typeContent.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow + + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(R.drawable.ic_mimetype_audio), + contentDescription = null, + modifier = Modifier + .padding(end = 4.dp) + .size(24.dp) + ) + + Text( + text = typeContent.fileName, + color = colorScheme.onPrimaryContainer, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + if (typeContent.isDownloading) { + CircularProgressIndicator(modifier = Modifier.size(48.dp), strokeWidth = 2.dp) + } else { + IconButton( + onClick = { onPlayPauseClick(message.id) }, + modifier = Modifier.size(48.dp) + ) { + Icon( + imageVector = icon, + contentDescription = stringResource(R.string.play_pause_audio_file), + modifier = Modifier.size(40.dp) + ) + } + } + + var sliderValue by remember { mutableFloatStateOf(0f) } + sliderValue = typeContent.seekbarProgress * 1f / SEEKBAR_MAX + + Slider( + value = sliderValue, + onValueChange = { + val progressI = (it * SEEKBAR_MAX).toInt() + onSeek(message.id, progressI) + }, + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.onPrimaryContainer.copy(alpha = INACTIVE_TRACK_ALPHA) + ), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + } + + Text( + text = DateUtils.formatElapsedTime(remainingSeconds.toLong()), + color = colorScheme.onPrimaryContainer, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + ) +} + +@ChatMessagePreviews +@Composable +private fun AudioFileMessagePreview() { + PreviewContainer { + AudioFileMessage( + typeContent = MessageTypeContent.AudioFile( + fileName = "podcast-episode-42.mp3", + isPlaying = false, + isDownloading = false, + durationSeconds = 245, + playedSeconds = 60, + seekbarProgress = 24 + ), + message = createBaseMessageWithoutCaption( + MessageTypeContent.AudioFile( + fileName = "podcast-episode-42.mp3", + isPlaying = false, + isDownloading = false, + durationSeconds = 245, + playedSeconds = 60, + seekbarProgress = 24 + ) + ) + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt index 58cf5c100c2..96d6b7d7099 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageScaffold.kt @@ -753,6 +753,11 @@ fun CommonMessageQuote(message: ChatMessageUi, contentMinWidth: Dp = 0.dp) { iconRes = R.drawable.ic_baseline_mic_24, label = stringResource(R.string.nc_voice_message) ) + is MessageTypeContent.AudioFile -> QuoteIconRow( + actorDisplayName = message.actorDisplayName, + iconRes = R.drawable.ic_mimetype_audio, + label = c.fileName + ) is MessageTypeContent.Poll -> QuoteIconRow( actorDisplayName = message.actorDisplayName, iconRes = R.drawable.ic_baseline_bar_chart_24, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt index 40304d0f7fa..95cb0549711 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -188,6 +188,17 @@ fun ChatMessageView( ) } + is MessageTypeContent.AudioFile -> { + AudioFileMessage( + typeContent = content, + message = message, + isOneToOneConversation = context.isOneToOneConversation, + conversationThreadId = context.conversationThreadId, + onPlayPauseClick = callbacks.onVoicePlayPauseClick, + onSeek = callbacks.onVoiceSeek + ) + } + is MessageTypeContent.Poll -> { PollMessage( typeContent = content, diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 8775d023690..e494f1dd838 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -28,15 +28,11 @@ import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.fullscreenfile.FullScreenMediaActivity import com.nextcloud.talk.fullscreenfile.FullScreenTextViewerActivity import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp -import com.nextcloud.talk.utils.Mimetype.AUDIO_MPEG -import com.nextcloud.talk.utils.Mimetype.AUDIO_OGG -import com.nextcloud.talk.utils.Mimetype.AUDIO_WAV import com.nextcloud.talk.utils.Mimetype.IMAGE_GIF import com.nextcloud.talk.utils.Mimetype.IMAGE_HEIC import com.nextcloud.talk.utils.Mimetype.IMAGE_JPEG @@ -49,7 +45,6 @@ import com.nextcloud.talk.utils.Mimetype.VIDEO_OGG import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX import com.nextcloud.talk.utils.Mimetype.VIDEO_QUICKTIME import com.nextcloud.talk.utils.Mimetype.VIDEO_WEBM -import com.nextcloud.talk.utils.MimetypeUtils.isAudioOnly import com.nextcloud.talk.utils.MimetypeUtils.isMarkdown import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID @@ -190,18 +185,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { private fun openFileByMimetype(filename: String, mimetype: String?, link: String? = null, fileId: String = "") { if (mimetype != null) { when (mimetype) { - AUDIO_MPEG, - AUDIO_WAV, - AUDIO_OGG -> openAudioView(filename, mimetype) - - // Reachable only if a future caller ends up here without the message/room context - // openFile(ChatMessage, ...) needs to route video to the media viewer instead - see - // openVideoInMediaViewer(). Kept as a safety net so video is never left unopenable. - VIDEO_MP4, - VIDEO_QUICKTIME, - VIDEO_OGG, - VIDEO_WEBM -> openVideoView(filename, mimetype) - TEXT_MARKDOWN, TEXT_PLAIN -> openTextView(filename, mimetype, link, fileId) @@ -268,20 +251,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { } } - private fun openAudioView(filename: String, mimetype: String) { - val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) - fullScreenMediaIntent.putExtra("FILE_NAME", filename) - fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype)) - context.startActivity(fullScreenMediaIntent) - } - - private fun openVideoView(filename: String, mimetype: String) { - val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) - fullScreenMediaIntent.putExtra("FILE_NAME", filename) - fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype)) - context.startActivity(fullScreenMediaIntent) - } - private fun openTextView(filename: String, mimetype: String, link: String?, fileId: String) { val fullScreenTextViewerIntent = Intent(context, FullScreenTextViewerActivity::class.java) fullScreenTextViewerIntent.putExtra("FILE_NAME", filename) @@ -299,9 +268,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { IMAGE_JPEG, IMAGE_HEIC, IMAGE_GIF, - AUDIO_MPEG, - AUDIO_WAV, - AUDIO_OGG, VIDEO_MP4, VIDEO_QUICKTIME, VIDEO_OGG, diff --git a/app/src/main/res/values-v27/styles.xml b/app/src/main/res/values-v27/styles.xml index 198bf5b6301..3ac79499228 100644 --- a/app/src/main/res/values-v27/styles.xml +++ b/app/src/main/res/values-v27/styles.xml @@ -19,18 +19,6 @@ ?alertDialogTheme - - - -