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
-
-
-
-