From 068a03578de06409d24601c3c6aac547ef92086e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 09:54:12 +0200 Subject: [PATCH 1/6] fix: shorten video controls auto-hide delay and stop system bar flicker Lower the ExoPlayer controller auto-hide timeout to 2s in the chat media viewer, and fix a feedback loop where hiding/showing the system bars re-triggered the controller to reappear mid fade-out, flickering forever: WindowInsets.systemBars animates across many frames while the bars hide/show, and reapplying the controller's child-view margins on every one of those frames fought the controller's own hide animation. Switch to WindowInsets.systemBarsIgnoringVisibility, which stays constant regardless of our own show/hide calls, in both the chat media viewer and the full-screen file viewer. Also drop the now-dead video branch in FullScreenMediaActivity/ FileViewerUtils: video attachments are routed to the media viewer before mimetype dispatch ever reaches it, so FullScreenMediaActivity only ever serves audio now. Removes the AUDIO_ONLY intent extra and isAudioOnly plumbing accordingly. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../fullscreenfile/FullScreenMediaActivity.kt | 2 - .../fullscreenfile/FullScreenMediaScreen.kt | 46 +++++++++++-------- .../activities/MediaViewerScreen.kt | 16 +++++-- .../nextcloud/talk/utils/FileViewerUtils.kt | 21 +-------- 4 files changed, 40 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt index bdaa2c23b61..53720112594 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt @@ -66,7 +66,6 @@ class FullScreenMediaActivity : AppCompatActivity() { 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() @@ -95,7 +94,6 @@ class FullScreenMediaActivity : AppCompatActivity() { FullScreenMediaScreen( title = fileName, player = player, - isAudioOnly = isAudioOnly, actions = FullScreenMediaActions( onShare = { shareFile() }, onSave = { showSaveDialog() }, diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt index e5ce478b073..02de929575b 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt @@ -18,11 +18,12 @@ import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi 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.systemBarsIgnoringVisibility import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -31,6 +32,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,7 +60,7 @@ private const val TOOLBAR_ALPHA = 0.5f @OptIn(UnstableApi::class, ExperimentalMaterial3Api::class) @Composable -fun FullScreenMediaScreen(title: String, player: ExoPlayer?, isAudioOnly: Boolean, actions: FullScreenMediaActions) { +fun FullScreenMediaScreen(title: String, player: ExoPlayer?, actions: FullScreenMediaActions) { val toolbarColors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, titleContentColor = Color.White, @@ -71,7 +73,6 @@ fun FullScreenMediaScreen(title: String, player: ExoPlayer?, isAudioOnly: Boolea Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { MediaPlayerView( player = player, - isAudioOnly = isAudioOnly, onControllerVisible = { showToolbar = true actions.onExitImmersive() @@ -91,13 +92,9 @@ fun FullScreenMediaScreen(title: String, player: ExoPlayer?, isAudioOnly: Boolea } @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalLayoutApi::class) @Composable -private fun MediaPlayerView( - player: ExoPlayer?, - isAudioOnly: Boolean, - onControllerVisible: () -> Unit, - onControllerHidden: () -> Unit -) { +private fun MediaPlayerView(player: ExoPlayer?, onControllerVisible: () -> Unit, onControllerHidden: () -> Unit) { if (LocalInspectionMode.current) { Box(modifier = Modifier.fillMaxSize()) return @@ -105,27 +102,38 @@ private fun MediaPlayerView( 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) + // Deliberately "IgnoringVisibility": the plain systemBars value animates across many frames + // while we hide/show the bars (see onEnterImmersive/onExitImmersive), and reapplying these + // margins on every one of those frames fights the ExoPlayer controller's own hide animation, + // flipping it back to visible mid-fade and looping forever. + val bottomPx = WindowInsets.systemBarsIgnoringVisibility.getBottom(density) + val leftPx = WindowInsets.systemBarsIgnoringVisibility.getLeft(density, layoutDirection) + val rightPx = WindowInsets.systemBarsIgnoringVisibility.getRight(density, layoutDirection) val originalProgressMarginBottom = remember { intArrayOf(-1) } + val playerViewRef = remember { mutableStateOf(null) } + + LaunchedEffect(player) { + playerViewRef.value?.apply { + this.player = player + } + } AndroidView( factory = { ctx -> PlayerView(ctx).apply { + // Audio only (this screen is no longer used for video - see FileViewerUtils): + // keep the controls up indefinitely rather than auto-hiding them. + controllerShowTimeoutMs = 0 showController() - if (isAudioOnly) { - controllerShowTimeoutMs = 0 - } setControllerVisibilityListener( PlayerView.ControllerVisibilityListener { visibility -> if (visibility == View.VISIBLE) onControllerVisible() else onControllerHidden() } ) + playerViewRef.value = this } }, update = { playerView -> - playerView.player = player val exoControls = playerView.findViewById(R.id.exo_bottom_bar) val exoProgress = playerView.findViewById(R.id.exo_progress) exoControls?.apply { @@ -194,9 +202,8 @@ data class FullScreenMediaActions( private fun PreviewFullScreenMediaLight() { MaterialTheme(colorScheme = lightColorScheme()) { FullScreenMediaScreen( - title = "video.mp4", + title = "audio.mp3", player = null, - isAudioOnly = false, actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) ) } @@ -207,9 +214,8 @@ private fun PreviewFullScreenMediaLight() { private fun PreviewFullScreenMediaDarkRtlArabic() { MaterialTheme(colorScheme = darkColorScheme()) { FullScreenMediaScreen( - title = "فيديو.mp4", + title = "صوت.mp3", player = null, - isAudioOnly = false, actions = FullScreenMediaActions(onShare = {}, onSave = {}, onEnterImmersive = {}, onExitImmersive = {}) ) } 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..0eb9a3b7762 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 @@ -390,6 +392,7 @@ private fun ImagePage(localPath: String, onToggleControls: () -> Unit) { // 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 +401,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 +416,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/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 8775d023690..a1207d22d0f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -49,7 +49,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 @@ -192,15 +191,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { 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) + AUDIO_OGG -> openAudioView(filename) TEXT_MARKDOWN, TEXT_PLAIN -> openTextView(filename, mimetype, link, fileId) @@ -268,17 +259,9 @@ 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) { + private fun openAudioView(filename: String) { val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) fullScreenMediaIntent.putExtra("FILE_NAME", filename) - fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype)) context.startActivity(fullScreenMediaIntent) } From 29d85ef10445e5388641b5f7a7157bda1919e757 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 11:46:55 +0200 Subject: [PATCH 2/6] feat: play generic audio attachments inline, remove FullScreenMediaActivity Extend the voice-message playback infrastructure to also handle generic (non-voice) audio file attachments, so they play inline in the chat bubble like voice messages do, instead of opening a separate full-screen activity. The new bubble is deliberately distinct from a voice message: a plain Material3 slider instead of a waveform, a generic file icon, the real filename shown, and no playback-speed control - both because a long audio file (podcast, song) is expensive to waveform-decode client-side the way short voice recordings are, and because the difference itself makes it visually obvious this is a file, not a voice note. - ChatMessageUi: new MessageTypeContent.AudioFile case, dispatched for audio-mimetype attachments; reuses ChatMessage's existing voice playback-state fields rather than adding a parallel set, since actual playback already goes through the same MediaController/ VoiceMessageMediaService session for both message types. - ChatViewModel.syncVoiceMessageUiState: now updates AudioFile content too, not just Voice - without this, audio-file playback would run but the UI would never reflect it. - ChatActivity: gate waveform decoding to real voice messages only, and show the filename (not "Voice Message") in the system media notification for audio files. - FileViewerUtils: removed openAudioView() and its dispatch branch - audio files never call onFileClick/openFile anymore, same as voice messages never did. - Shared Items gallery (Audio/Voice/Recording tabs): tapping a row now jumps to and highlights the message in chat, reusing the same navigate-to-message intent pattern already used by search results, instead of opening a player from the gallery. - Removed FullScreenMediaActivity/FullScreenMediaScreen entirely (its manifest entry, both FullScreenMediaTheme style variants, and the AUDIO_ONLY intent extra it existed for) now that nothing routes audio playback through it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 5 - .../com/nextcloud/talk/chat/ChatActivity.kt | 11 +- .../talk/chat/ui/model/ChatMessageUi.kt | 28 +++ .../talk/chat/viewmodels/ChatViewModel.kt | 47 ++-- .../fullscreenfile/FullScreenMediaActivity.kt | 187 --------------- .../fullscreenfile/FullScreenMediaScreen.kt | 222 ------------------ .../activities/MediaViewerActivity.kt | 11 +- .../activities/MediaViewerScreen.kt | 11 +- .../adapters/SharedItemsAdapter.kt | 16 +- .../adapters/SharedItemsListViewHolder.kt | 8 +- .../adapters/SharedItemsViewHolder.kt | 14 +- .../talk/ui/chat/AudioFileMessage.kt | 154 ++++++++++++ .../talk/ui/chat/ChatMessageScaffold.kt | 5 + .../nextcloud/talk/ui/chat/ChatMessageView.kt | 11 + .../nextcloud/talk/utils/FileViewerUtils.kt | 17 -- app/src/main/res/values-v27/styles.xml | 12 - app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 11 - 18 files changed, 281 insertions(+), 490 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt 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 + + // Deliberately lighter than Voice: no waveform (decoding one for a long audio file, + // e.g. a podcast, would be slow, unlike short voice recordings) and no playback speed + // control - both differences also make the bubble read as "a file", not "a voice note". + 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 +282,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 +407,16 @@ fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = seekbarProgress = message.voiceMessageSeekbarProgress, waveform = message.voiceMessageFloatArray?.toList().orEmpty() ) + +// Generic (non-voice) audio file attachments are played back through the same +// MediaController/VoiceMessageMediaService session as voice messages, so they reuse the +// same ChatMessage playback-state fields. +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..e6b38588bc0 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 @@ -1090,27 +1090,42 @@ class ChatViewModel @AssistedInject constructor( var currentVoiceMessage: ChatMessage? = null + // Drives both voice messages and generic audio files - they share the same MediaController + // playback session and the same ChatMessage playback-state fields (see onVoiceClick in + // ChatActivity), so this must keep both UI content types in sync. fun syncVoiceMessageUiState(message: ChatMessage) { currentVoiceMessage = message _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 53720112594..00000000000 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt +++ /dev/null @@ -1,187 +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() - 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, - 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 02de929575b..00000000000 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaScreen.kt +++ /dev/null @@ -1,222 +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.ExperimentalLayoutApi -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.systemBarsIgnoringVisibility -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.LaunchedEffect -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?, 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, - 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) -@kotlin.OptIn(ExperimentalLayoutApi::class) -@Composable -private fun MediaPlayerView(player: ExoPlayer?, onControllerVisible: () -> Unit, onControllerHidden: () -> Unit) { - if (LocalInspectionMode.current) { - Box(modifier = Modifier.fillMaxSize()) - return - } - - val density = LocalDensity.current - val layoutDirection = LocalLayoutDirection.current - // Deliberately "IgnoringVisibility": the plain systemBars value animates across many frames - // while we hide/show the bars (see onEnterImmersive/onExitImmersive), and reapplying these - // margins on every one of those frames fights the ExoPlayer controller's own hide animation, - // flipping it back to visible mid-fade and looping forever. - val bottomPx = WindowInsets.systemBarsIgnoringVisibility.getBottom(density) - val leftPx = WindowInsets.systemBarsIgnoringVisibility.getLeft(density, layoutDirection) - val rightPx = WindowInsets.systemBarsIgnoringVisibility.getRight(density, layoutDirection) - val originalProgressMarginBottom = remember { intArrayOf(-1) } - val playerViewRef = remember { mutableStateOf(null) } - - LaunchedEffect(player) { - playerViewRef.value?.apply { - this.player = player - } - } - - AndroidView( - factory = { ctx -> - PlayerView(ctx).apply { - // Audio only (this screen is no longer used for video - see FileViewerUtils): - // keep the controls up indefinitely rather than auto-hiding them. - controllerShowTimeoutMs = 0 - showController() - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visibility -> - if (visibility == View.VISIBLE) onControllerVisible() else onControllerHidden() - } - ) - playerViewRef.value = this - } - }, - update = { playerView -> - 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 = "audio.mp3", - player = null, - 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 = "صوت.mp3", - player = null, - 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..3edc0d1120f 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,11 @@ 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. + // Deliberately no SwipeToCloseLayout here: 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 0eb9a3b7762..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 @@ -165,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) } @@ -387,10 +386,8 @@ 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 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..6e26335ed79 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,17 @@ class SharedItemsAdapter( context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) } + // Audio/voice/recording items have no player of their own in this gallery - tapping one + // jumps to and highlights the message in the chat instead, reusing the same + // navigate-to-message pipeline search results and quote taps already use. + 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..8275ae7e79a 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,13 @@ abstract class SharedItemsViewHolder( return } + if (item.mimeType.startsWith(Mimetype.AUDIO_PREFIX)) { + // Audio/voice/recording items play back inline in the chat message bubble - see + // SharedItemsAdapter.openInChat(). + 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..f1c6c8b2ac9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt @@ -0,0 +1,154 @@ +/* + * 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.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.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 + +// Deliberately plainer than VoiceMessage: a stock Material3 Slider (no waveform track) and +// a generic file icon + filename instead of the mic-associated waveform look, so a generic +// audio attachment reads as "a file", not "a voice note", at a glance. +@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 = 1 + ) + } + + 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) + }, + 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 a1207d22d0f..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 @@ -189,10 +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) - TEXT_MARKDOWN, TEXT_PLAIN -> openTextView(filename, mimetype, link, fileId) @@ -259,12 +251,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { } } - private fun openAudioView(filename: String) { - val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) - fullScreenMediaIntent.putExtra("FILE_NAME", filename) - 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) @@ -282,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 - - - - From f01be1d30a863f93e9d3564be0538442472de520 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 12:31:57 +0200 Subject: [PATCH 3/6] fix: improve contrast of the audio-file seek bar The plain Material3 Slider used its default colors (primary/ surfaceVariant), which read as nearly invisible against the message bubble background. Give it explicit colors instead: primary for the thumb/played portion (the app's normal accent, recognizable without being the punchier inversePrimary voice messages' waveform uses), and a 40%-alpha onPrimaryContainer for the unplayed rail, visible without looking as bold as full-strength text-contrast color. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/ui/chat/AudioFileMessage.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index f1c6c8b2ac9..5daecc88dd6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt @@ -22,6 +22,7 @@ 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 @@ -38,6 +39,7 @@ 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 // Deliberately plainer than VoiceMessage: a stock Material3 Slider (no waveform track) and // a generic file icon + filename instead of the mic-associated waveform look, so a generic @@ -110,6 +112,17 @@ fun AudioFileMessage( val progressI = (it * SEEKBAR_MAX).toInt() onSeek(message.id, progressI) }, + // Material3's own defaults (primary/surfaceVariant) read as near-invisible + // against the message bubble, but ComposeWaveformSeekBar's full-strength + // palette (inversePrimary/onPrimaryContainer) was too heavy for a plain + // track - primary keeps the thumb/played portion recognizable as the + // app's normal accent, and a muted onPrimaryContainer keeps the unplayed + // rail visible without it reading as bold text-strength contrast. + colors = SliderDefaults.colors( + thumbColor = colorScheme.primary, + activeTrackColor = colorScheme.primary, + inactiveTrackColor = colorScheme.onPrimaryContainer.copy(alpha = INACTIVE_TRACK_ALPHA) + ), modifier = Modifier .fillMaxWidth() .padding(8.dp) From eab82517e4feb6ee86024bcc76bc30e2b50bcfb9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 13:40:18 +0200 Subject: [PATCH 4/6] fix: audio-file playback getting stuck or failing on first play Two related bugs in onVoiceClick's prepare-and-play flow, both only reachable now that generic audio files (not just voice messages) go through it: - isDownloadingVoiceMessage was only ever reset back to false inside setUpWaveform, once waveform decoding finished. Audio files skip waveform decoding entirely, so once downloadFileToCache set it true, nothing ever cleared it - the spinner stayed up forever and the play/pause button became permanently unreachable. Extracted the shared "start playback" step into finishPreparing(), which now resets the flag directly for non-voice messages instead of relying on setUpWaveform to do it. - The MediaController's currentMediaItem can outlive the local cache file it points to - e.g. clearing the app's cache only deletes files on disk, not the (service-hosted, long-lived) MediaController's in-memory state. Re-preparation was skipped whenever a message's mediaId already matched the controller's loaded item, regardless of whether the file was actually still there, falling through to a bare controller.play() on a stale reference that could never succeed (ExoPlayer FileNotFoundException) and leaving pause/resume unreachable. Now also re-prepares (and so re-downloads) whenever the file is missing, even if the mediaId already matches. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index a5aeb92bd42..6cd4ce8340b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1186,7 +1186,28 @@ class ChatActivity : } ) - if (controller.currentMediaItem?.mediaId != currentMessageId) { + // Only voice messages get a waveform (and its own reset of isDownloadingVoiceMessage + // once decoded, see setUpWaveform) - audio files need it reset here instead, since + // downloadFileToCache already set it true and nothing else would ever clear it, + // leaving the spinner stuck (and the play/pause button unreachable) forever. + fun finishPreparing() { + setupAndPlay(controller, message, filePath) + if (message.isVoiceMessage) { + setUpWaveform(message, file) + } else { + message.isDownloadingVoiceMessage = false + chatViewModel.syncVoiceMessageUiState(message) + } + } + + // The controller's currentMediaItem can outlive the local cache file it points to - + // e.g. clearing the app's cache doesn't reset the (long-lived, service-hosted) + // MediaController's in-memory state, only the files on disk. Re-prepare (and so + // re-download) whenever the file is missing, even if this message's mediaId is + // already "loaded" - otherwise this falls through to a bare controller.play() on a + // stale reference that can never succeed, and pause/resume becomes unreachable. + val alreadyLoaded = controller.currentMediaItem?.mediaId == currentMessageId + if (!alreadyLoaded || !file.exists()) { if (!file.exists()) { downloadFileToCache(message, true) { chatViewModel.syncVoiceMessageUiState( @@ -1194,12 +1215,10 @@ class ChatActivity : voiceMessageDuration = getAudioDuration(file.absolutePath).toInt() } ) - setupAndPlay(controller, message, filePath) - if (message.isVoiceMessage) setUpWaveform(message, file) + finishPreparing() } } else { - setupAndPlay(controller, message, filePath) - if (message.isVoiceMessage) setUpWaveform(message, file) + finishPreparing() } return true From 0f343d98cdc9b67b812f03eba1b9605a55f92b54 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 13:51:23 +0200 Subject: [PATCH 5/6] fix: wrap long audio-file names onto two lines before truncating The filename was single-line with no overflow handling and no width constraint of its own, so a long name could run past the message bubble's edge instead of wrapping or truncating. Allow it to wrap onto a second line first, only ellipsizing if it's still too long, and constrain it to the row's remaining width (after the icon) so it wraps within the bubble instead of overflowing past it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 5daecc88dd6..c04b363525c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt @@ -33,6 +33,7 @@ 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 @@ -80,7 +81,9 @@ fun AudioFileMessage( Text( text = typeContent.fileName, color = colorScheme.onPrimaryContainer, - maxLines = 1 + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) } From e7be8af7dc920caf206d445487b27122ae19c099 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 18 Sep 2026 14:14:38 +0200 Subject: [PATCH 6/6] remove comments Signed-off-by: Marcel Hibbe --- .../main/java/com/nextcloud/talk/chat/ChatActivity.kt | 10 ---------- .../com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt | 6 ------ .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 3 --- .../talk/mediaviewer/activities/MediaViewerActivity.kt | 5 ----- .../talk/shareditems/adapters/SharedItemsAdapter.kt | 3 --- .../talk/shareditems/adapters/SharedItemsViewHolder.kt | 2 -- .../com/nextcloud/talk/ui/chat/AudioFileMessage.kt | 9 --------- 7 files changed, 38 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 6cd4ce8340b..946f829f83a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1186,10 +1186,6 @@ class ChatActivity : } ) - // Only voice messages get a waveform (and its own reset of isDownloadingVoiceMessage - // once decoded, see setUpWaveform) - audio files need it reset here instead, since - // downloadFileToCache already set it true and nothing else would ever clear it, - // leaving the spinner stuck (and the play/pause button unreachable) forever. fun finishPreparing() { setupAndPlay(controller, message, filePath) if (message.isVoiceMessage) { @@ -1200,12 +1196,6 @@ class ChatActivity : } } - // The controller's currentMediaItem can outlive the local cache file it points to - - // e.g. clearing the app's cache doesn't reset the (long-lived, service-hosted) - // MediaController's in-memory state, only the files on disk. Re-prepare (and so - // re-download) whenever the file is missing, even if this message's mediaId is - // already "loaded" - otherwise this falls through to a bare controller.play() on a - // stale reference that can never succeed, and pause/resume becomes unreachable. val alreadyLoaded = controller.currentMediaItem?.mediaId == currentMessageId if (!alreadyLoaded || !file.exists()) { if (!file.exists()) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt index f26dab8788e..f475d2c762a 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt @@ -109,9 +109,6 @@ sealed interface MessageTypeContent { val playbackSpeed: PlaybackSpeed = PlaybackSpeed.NORMAL ) : MessageTypeContent - // Deliberately lighter than Voice: no waveform (decoding one for a long audio file, - // e.g. a podcast, would be slow, unlike short voice recordings) and no playback speed - // control - both differences also make the bubble read as "a file", not "a voice note". data class AudioFile( val fileName: String, val isPlaying: Boolean, @@ -408,9 +405,6 @@ fun getVoiceContent(message: ChatMessage): MessageTypeContent.Voice = waveform = message.voiceMessageFloatArray?.toList().orEmpty() ) -// Generic (non-voice) audio file attachments are played back through the same -// MediaController/VoiceMessageMediaService session as voice messages, so they reuse the -// same ChatMessage playback-state fields. fun getAudioFileContent(message: ChatMessage): MessageTypeContent.AudioFile = MessageTypeContent.AudioFile( fileName = message.fileParameters.name, 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 e6b38588bc0..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 @@ -1090,9 +1090,6 @@ class ChatViewModel @AssistedInject constructor( var currentVoiceMessage: ChatMessage? = null - // Drives both voice messages and generic audio files - they share the same MediaController - // playback session and the same ChatMessage playback-state fields (see onVoiceClick in - // ChatActivity), so this must keep both UI content types in sync. fun syncVoiceMessageUiState(message: ChatMessage) { currentVoiceMessage = message _uiState.update { current -> 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 3edc0d1120f..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,11 +76,6 @@ class MediaViewerActivity : BaseActivity() { windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - // Deliberately no SwipeToCloseLayout here: 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/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index 6e26335ed79..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 @@ -124,9 +124,6 @@ class SharedItemsAdapter( context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) } - // Audio/voice/recording items have no player of their own in this gallery - tapping one - // jumps to and highlights the message in the chat instead, reusing the same - // navigate-to-message pipeline search results and quote taps already use. private fun openInChat(item: SharedFileItem, context: Context) { val intent = Intent(context, ChatActivity::class.java).apply { putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken) 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 8275ae7e79a..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 @@ -66,8 +66,6 @@ abstract class SharedItemsViewHolder( } if (item.mimeType.startsWith(Mimetype.AUDIO_PREFIX)) { - // Audio/voice/recording items play back inline in the chat message bubble - see - // SharedItemsAdapter.openInChat(). clickTarget.setOnClickListener { openInChat(item, image.context) } return } 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 index c04b363525c..2c85897e1bf 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/AudioFileMessage.kt @@ -42,9 +42,6 @@ import com.nextcloud.talk.chat.ui.model.MessageTypeContent private const val SEEKBAR_MAX = 100 private const val INACTIVE_TRACK_ALPHA = 0.4f -// Deliberately plainer than VoiceMessage: a stock Material3 Slider (no waveform track) and -// a generic file icon + filename instead of the mic-associated waveform look, so a generic -// audio attachment reads as "a file", not "a voice note", at a glance. @OptIn(ExperimentalMaterial3Api::class) @Suppress("Detekt.LongMethod") @Composable @@ -115,12 +112,6 @@ fun AudioFileMessage( val progressI = (it * SEEKBAR_MAX).toInt() onSeek(message.id, progressI) }, - // Material3's own defaults (primary/surfaceVariant) read as near-invisible - // against the message bubble, but ComposeWaveformSeekBar's full-strength - // palette (inversePrimary/onPrimaryContainer) was too heavy for a plain - // track - primary keeps the thumb/played portion recognizable as the - // app's normal accent, and a muted onPrimaryContainer keeps the unplayed - // rail visible without it reading as bold text-strength contrast. colors = SliderDefaults.colors( thumbColor = colorScheme.primary, activeTrackColor = colorScheme.primary,