feat: improve chat media and notification interactions
fix: reveal spoiler text on tap with animated transitions fix: render and play circle videos correctly on web feat: add quick reply and mark-as-read notification actions fix: stabilize circle recording gestures and chat auxiliary error handling
This commit is contained in:
@@ -51,6 +51,9 @@
|
|||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name=".core.notifications.NotificationActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ru.daemonlord.messenger.core.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class NotificationActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var sendTextMessageUseCase: SendTextMessageUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var markMessageReadUseCase: MarkMessageReadUseCase
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var notificationDispatcher: NotificationDispatcher
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val chatId = intent.getLongExtra(NotificationIntentExtras.EXTRA_CHAT_ID, -1L)
|
||||||
|
if (chatId <= 0L) return
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
try {
|
||||||
|
when (intent.action) {
|
||||||
|
NotificationIntentExtras.ACTION_REPLY -> handleReply(intent, chatId)
|
||||||
|
NotificationIntentExtras.ACTION_MARK_READ -> handleMarkRead(intent, chatId)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleReply(intent: Intent, chatId: Long) {
|
||||||
|
val replyText = RemoteInput.getResultsFromIntent(intent)
|
||||||
|
?.getCharSequence(NotificationIntentExtras.EXTRA_NOTIFICATION_REPLY_TEXT)
|
||||||
|
?.toString()
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
if (replyText.isBlank()) return
|
||||||
|
sendTextMessageUseCase(chatId = chatId, text = replyText)
|
||||||
|
val messageId = intent.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L)
|
||||||
|
if (messageId > 0L) {
|
||||||
|
markMessageReadUseCase(chatId = chatId, messageId = messageId)
|
||||||
|
}
|
||||||
|
notificationDispatcher.clearChatNotifications(chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMarkRead(intent: Intent, chatId: Long) {
|
||||||
|
val messageId = intent.getLongExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, -1L)
|
||||||
|
if (messageId > 0L) {
|
||||||
|
markMessageReadUseCase(chatId = chatId, messageId = messageId)
|
||||||
|
}
|
||||||
|
notificationDispatcher.clearChatNotifications(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import android.content.pm.PackageManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import ru.daemonlord.messenger.R
|
||||||
import ru.daemonlord.messenger.MainActivity
|
import ru.daemonlord.messenger.MainActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -51,6 +53,31 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
openIntent,
|
openIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
val replyRemoteInput = RemoteInput.Builder(NotificationIntentExtras.EXTRA_NOTIFICATION_REPLY_TEXT)
|
||||||
|
.setLabel(context.getString(R.string.notification_action_reply_hint))
|
||||||
|
.build()
|
||||||
|
val replyIntent = Intent(context, NotificationActionReceiver::class.java)
|
||||||
|
.setAction(NotificationIntentExtras.ACTION_REPLY)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_TITLE, payload.title)
|
||||||
|
val replyPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
chatNotificationId(payload.chatId) + ACTION_REQUEST_CODE_REPLY_OFFSET,
|
||||||
|
replyIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||||
|
)
|
||||||
|
val markReadIntent = Intent(context, NotificationActionReceiver::class.java)
|
||||||
|
.setAction(NotificationIntentExtras.ACTION_MARK_READ)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_ID, payload.chatId)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_MESSAGE_ID, payload.messageId ?: -1L)
|
||||||
|
.putExtra(NotificationIntentExtras.EXTRA_CHAT_TITLE, payload.title)
|
||||||
|
val markReadPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
chatNotificationId(payload.chatId) + ACTION_REQUEST_CODE_READ_OFFSET,
|
||||||
|
markReadIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
val contentText = when {
|
val contentText = when {
|
||||||
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
state.unreadCount <= 1 -> state.lines.firstOrNull() ?: payload.body
|
||||||
@@ -74,6 +101,23 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
.setPriority(
|
.setPriority(
|
||||||
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
if (payload.isMention) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT
|
||||||
)
|
)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
0,
|
||||||
|
context.getString(R.string.notification_action_mark_read),
|
||||||
|
markReadPendingIntent,
|
||||||
|
).build(),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
NotificationCompat.Action.Builder(
|
||||||
|
0,
|
||||||
|
context.getString(R.string.notification_action_reply),
|
||||||
|
replyPendingIntent,
|
||||||
|
)
|
||||||
|
.addRemoteInput(replyRemoteInput)
|
||||||
|
.setAllowGeneratedReplies(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
val manager = NotificationManagerCompat.from(context)
|
||||||
@@ -166,5 +210,7 @@ class NotificationDispatcher @Inject constructor(
|
|||||||
private const val GROUP_KEY_CHATS = "messenger_chats_group"
|
private const val GROUP_KEY_CHATS = "messenger_chats_group"
|
||||||
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
|
private const val SUMMARY_NOTIFICATION_ID = 0x4D53_4752 // "MSGR"
|
||||||
private const val MAX_LINES = 5
|
private const val MAX_LINES = 5
|
||||||
|
private const val ACTION_REQUEST_CODE_REPLY_OFFSET = 101
|
||||||
|
private const val ACTION_REQUEST_CODE_READ_OFFSET = 202
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,9 @@ data class ChatNotificationPayload(
|
|||||||
object NotificationIntentExtras {
|
object NotificationIntentExtras {
|
||||||
const val EXTRA_CHAT_ID = "extra_chat_id"
|
const val EXTRA_CHAT_ID = "extra_chat_id"
|
||||||
const val EXTRA_MESSAGE_ID = "extra_message_id"
|
const val EXTRA_MESSAGE_ID = "extra_message_id"
|
||||||
|
const val EXTRA_CHAT_TITLE = "extra_chat_title"
|
||||||
|
const val EXTRA_NOTIFICATION_ACTION = "extra_notification_action"
|
||||||
|
const val EXTRA_NOTIFICATION_REPLY_TEXT = "extra_notification_reply_text"
|
||||||
|
const val ACTION_MARK_READ = "ru.daemonlord.messenger.action.MARK_READ"
|
||||||
|
const val ACTION_REPLY = "ru.daemonlord.messenger.action.REPLY"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -117,6 +120,7 @@ import androidx.compose.foundation.layout.WindowInsets
|
|||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.input.pointer.positionChange
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
@@ -213,7 +217,6 @@ import androidx.media3.common.Player
|
|||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.effect.Crop
|
import androidx.media3.effect.Crop
|
||||||
import androidx.media3.effect.Presentation
|
import androidx.media3.effect.Presentation
|
||||||
import androidx.media3.effect.ScaleAndRotateTransformation
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.DefaultTimeBar
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
@@ -239,6 +242,8 @@ fun ChatRoute(
|
|||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val voiceRecorder = remember(context) { VoiceRecorder(context) }
|
val voiceRecorder = remember(context) { VoiceRecorder(context) }
|
||||||
var showCircleRecorder by remember { mutableStateOf(false) }
|
var showCircleRecorder by remember { mutableStateOf(false) }
|
||||||
|
var isCircleRecordingLocked by remember { mutableStateOf(false) }
|
||||||
|
var pendingCircleFinalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
||||||
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
|
var cameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
|
||||||
var hasAudioPermission by remember {
|
var hasAudioPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -259,6 +264,11 @@ fun ChatRoute(
|
|||||||
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
|
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
|
||||||
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
|
var openCircleAfterPermissionGrant by remember { mutableStateOf(false) }
|
||||||
var pendingCameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
|
var pendingCameraCaptureMode by remember { mutableStateOf<CameraCaptureMode?>(null) }
|
||||||
|
val openCircleRecorder: () -> Unit = {
|
||||||
|
pendingCircleFinalizeAction = null
|
||||||
|
isCircleRecordingLocked = false
|
||||||
|
showCircleRecorder = true
|
||||||
|
}
|
||||||
val startVoiceRecording: () -> Unit = {
|
val startVoiceRecording: () -> Unit = {
|
||||||
val started = voiceRecorder.start()
|
val started = voiceRecorder.start()
|
||||||
if (started) {
|
if (started) {
|
||||||
@@ -284,8 +294,8 @@ fun ChatRoute(
|
|||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||||
hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true ||
|
hasAudioPermission = grants[Manifest.permission.RECORD_AUDIO] == true ||
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||||
if (hasCameraPermission && openCircleAfterPermissionGrant) {
|
if (hasCameraPermission && hasAudioPermission && openCircleAfterPermissionGrant) {
|
||||||
showCircleRecorder = true
|
openCircleRecorder()
|
||||||
}
|
}
|
||||||
openCircleAfterPermissionGrant = false
|
openCircleAfterPermissionGrant = false
|
||||||
val pendingMode = pendingCameraCaptureMode
|
val pendingMode = pendingCameraCaptureMode
|
||||||
@@ -354,8 +364,8 @@ fun ChatRoute(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCaptureCircleVideo = {
|
onCaptureCircleVideo = {
|
||||||
if (hasCameraPermission) {
|
if (hasCameraPermission && hasAudioPermission) {
|
||||||
showCircleRecorder = true
|
openCircleRecorder()
|
||||||
} else {
|
} else {
|
||||||
openCircleAfterPermissionGrant = true
|
openCircleAfterPermissionGrant = true
|
||||||
circlePermissionLauncher.launch(
|
circlePermissionLauncher.launch(
|
||||||
@@ -406,6 +416,22 @@ fun ChatRoute(
|
|||||||
viewModel.onVoiceRecordCancelled()
|
viewModel.onVoiceRecordCancelled()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
isCircleRecordLocked = isCircleRecordingLocked,
|
||||||
|
onCircleRecordLock = {
|
||||||
|
if (showCircleRecorder) {
|
||||||
|
isCircleRecordingLocked = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCircleRecordCancel = {
|
||||||
|
if (showCircleRecorder) {
|
||||||
|
pendingCircleFinalizeAction = CircleFinalizeAction.Cancel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCircleRecordSend = {
|
||||||
|
if (showCircleRecorder) {
|
||||||
|
pendingCircleFinalizeAction = CircleFinalizeAction.Send
|
||||||
|
}
|
||||||
|
},
|
||||||
onInlineSearchChanged = viewModel::onInlineSearchChanged,
|
onInlineSearchChanged = viewModel::onInlineSearchChanged,
|
||||||
onJumpInlineSearch = viewModel::jumpInlineSearch,
|
onJumpInlineSearch = viewModel::jumpInlineSearch,
|
||||||
onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId,
|
onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId,
|
||||||
@@ -423,13 +449,22 @@ fun ChatRoute(
|
|||||||
if (showCircleRecorder) {
|
if (showCircleRecorder) {
|
||||||
CircleVideoRecorderDialog(
|
CircleVideoRecorderDialog(
|
||||||
lifecycleOwner = lifecycleOwner,
|
lifecycleOwner = lifecycleOwner,
|
||||||
onDismiss = { showCircleRecorder = false },
|
isLocked = isCircleRecordingLocked,
|
||||||
|
pendingFinalizeAction = pendingCircleFinalizeAction,
|
||||||
|
onFinalizeActionConsumed = { pendingCircleFinalizeAction = null },
|
||||||
|
onDismiss = {
|
||||||
|
pendingCircleFinalizeAction = null
|
||||||
|
isCircleRecordingLocked = false
|
||||||
|
showCircleRecorder = false
|
||||||
|
},
|
||||||
onSend = { payload ->
|
onSend = { payload ->
|
||||||
viewModel.onMediaPicked(
|
viewModel.onMediaPicked(
|
||||||
fileName = payload.fileName,
|
fileName = payload.fileName,
|
||||||
mimeType = payload.mimeType,
|
mimeType = payload.mimeType,
|
||||||
bytes = payload.bytes,
|
bytes = payload.bytes,
|
||||||
)
|
)
|
||||||
|
pendingCircleFinalizeAction = null
|
||||||
|
isCircleRecordingLocked = false
|
||||||
showCircleRecorder = false
|
showCircleRecorder = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -481,6 +516,10 @@ private data class ChatScreenActions(
|
|||||||
val onVoiceRecordLock: () -> Unit,
|
val onVoiceRecordLock: () -> Unit,
|
||||||
val onVoiceRecordCancel: () -> Unit,
|
val onVoiceRecordCancel: () -> Unit,
|
||||||
val onVoiceRecordSend: () -> Unit,
|
val onVoiceRecordSend: () -> Unit,
|
||||||
|
val isCircleRecordLocked: Boolean,
|
||||||
|
val onCircleRecordLock: () -> Unit,
|
||||||
|
val onCircleRecordCancel: () -> Unit,
|
||||||
|
val onCircleRecordSend: () -> Unit,
|
||||||
val onInlineSearchChanged: (String) -> Unit,
|
val onInlineSearchChanged: (String) -> Unit,
|
||||||
val onJumpInlineSearch: (Boolean) -> Unit,
|
val onJumpInlineSearch: (Boolean) -> Unit,
|
||||||
val onVisibleIncomingMessageId: (Long?) -> Unit,
|
val onVisibleIncomingMessageId: (Long?) -> Unit,
|
||||||
@@ -529,6 +568,10 @@ private fun ChatScreen(
|
|||||||
val onVoiceRecordLock = actions.onVoiceRecordLock
|
val onVoiceRecordLock = actions.onVoiceRecordLock
|
||||||
val onVoiceRecordCancel = actions.onVoiceRecordCancel
|
val onVoiceRecordCancel = actions.onVoiceRecordCancel
|
||||||
val onVoiceRecordSend = actions.onVoiceRecordSend
|
val onVoiceRecordSend = actions.onVoiceRecordSend
|
||||||
|
val isCircleRecordLocked = actions.isCircleRecordLocked
|
||||||
|
val onCircleRecordLock = actions.onCircleRecordLock
|
||||||
|
val onCircleRecordCancel = actions.onCircleRecordCancel
|
||||||
|
val onCircleRecordSend = actions.onCircleRecordSend
|
||||||
val onInlineSearchChanged = actions.onInlineSearchChanged
|
val onInlineSearchChanged = actions.onInlineSearchChanged
|
||||||
val onJumpInlineSearch = actions.onJumpInlineSearch
|
val onJumpInlineSearch = actions.onJumpInlineSearch
|
||||||
val onVisibleIncomingMessageId = actions.onVisibleIncomingMessageId
|
val onVisibleIncomingMessageId = actions.onVisibleIncomingMessageId
|
||||||
@@ -1821,14 +1864,20 @@ private fun ChatScreen(
|
|||||||
} else {
|
} else {
|
||||||
UnifiedRecordButton(
|
UnifiedRecordButton(
|
||||||
enabled = state.canSendMessages && !state.isUploadingMedia,
|
enabled = state.canSendMessages && !state.isUploadingMedia,
|
||||||
isLocked = state.isVoiceLocked,
|
isLocked = if (useCircleRecording) isCircleRecordLocked else state.isVoiceLocked,
|
||||||
useCircleRecording = useCircleRecording,
|
useCircleRecording = useCircleRecording,
|
||||||
onToggleMode = { useCircleRecording = !useCircleRecording },
|
onToggleMode = { useCircleRecording = !useCircleRecording },
|
||||||
onStartVoice = onVoiceRecordStart,
|
onStartVoice = onVoiceRecordStart,
|
||||||
onStartCircle = onCaptureCircleVideo,
|
onStartCircle = onCaptureCircleVideo,
|
||||||
onLock = onVoiceRecordLock,
|
onLock = {
|
||||||
onCancel = onVoiceRecordCancel,
|
if (useCircleRecording) onCircleRecordLock() else onVoiceRecordLock()
|
||||||
onRelease = onVoiceRecordSend,
|
},
|
||||||
|
onCancel = {
|
||||||
|
if (useCircleRecording) onCircleRecordCancel() else onVoiceRecordCancel()
|
||||||
|
},
|
||||||
|
onRelease = {
|
||||||
|
if (useCircleRecording) onCircleRecordSend() else onVoiceRecordSend()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2055,6 +2104,7 @@ private fun ChatMediaViewerOverlay(
|
|||||||
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))) {
|
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0))) {
|
||||||
items.size
|
items.size
|
||||||
}
|
}
|
||||||
|
var showTopBar by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2066,22 +2116,28 @@ private fun ChatMediaViewerOverlay(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Row(
|
AnimatedVisibility(
|
||||||
modifier = Modifier
|
visible = showTopBar,
|
||||||
.fillMaxWidth()
|
enter = fadeIn(),
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
exit = fadeOut(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onDismiss) {
|
Row(
|
||||||
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(imageVector = Icons.Filled.Close, contentDescription = "Close viewer", tint = Color.White)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1}/${items.size.coerceAtLeast(1)}",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(48.dp))
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = "${pagerState.currentPage + 1}/${items.size.coerceAtLeast(1)}",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = Color.White,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(48.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
@@ -2093,11 +2149,13 @@ private fun ChatMediaViewerOverlay(
|
|||||||
ChatViewerMediaType.Image -> ZoomableImageViewerPage(
|
ChatViewerMediaType.Image -> ZoomableImageViewerPage(
|
||||||
imageUrl = item.url,
|
imageUrl = item.url,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
onToggleChrome = { showTopBar = !showTopBar },
|
||||||
)
|
)
|
||||||
ChatViewerMediaType.Video -> VideoViewerPage(
|
ChatViewerMediaType.Video -> VideoViewerPage(
|
||||||
videoUrl = item.url,
|
videoUrl = item.url,
|
||||||
isCurrentPage = pagerState.currentPage == page,
|
isCurrentPage = pagerState.currentPage == page,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
|
onToggleChrome = { showTopBar = !showTopBar },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2113,6 +2171,7 @@ private fun ChatMediaViewerOverlay(
|
|||||||
private fun ZoomableImageViewerPage(
|
private fun ZoomableImageViewerPage(
|
||||||
imageUrl: String,
|
imageUrl: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onToggleChrome: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var scale by remember(imageUrl) { mutableStateOf(1f) }
|
var scale by remember(imageUrl) { mutableStateOf(1f) }
|
||||||
var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) }
|
var offset by remember(imageUrl) { mutableStateOf(Offset.Zero) }
|
||||||
@@ -2150,6 +2209,11 @@ private fun ZoomableImageViewerPage(
|
|||||||
dismissOffsetY = 0f
|
dismissOffsetY = 0f
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
.pointerInput(imageUrl) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { onToggleChrome() },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
@@ -2175,6 +2239,7 @@ private fun VideoViewerPage(
|
|||||||
videoUrl: String,
|
videoUrl: String,
|
||||||
isCurrentPage: Boolean,
|
isCurrentPage: Boolean,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onToggleChrome: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val playerState = rememberManagedMediaPlayerState(
|
val playerState = rememberManagedMediaPlayerState(
|
||||||
url = videoUrl,
|
url = videoUrl,
|
||||||
@@ -2210,6 +2275,11 @@ private fun VideoViewerPage(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.pointerInput(videoUrl) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = { onToggleChrome() },
|
||||||
|
)
|
||||||
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationY = dismissOffsetY
|
translationY = dismissOffsetY
|
||||||
},
|
},
|
||||||
@@ -2911,6 +2981,9 @@ private fun CameraCaptureDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CircleVideoRecorderDialog(
|
private fun CircleVideoRecorderDialog(
|
||||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||||
|
isLocked: Boolean,
|
||||||
|
pendingFinalizeAction: CircleFinalizeAction?,
|
||||||
|
onFinalizeActionConsumed: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onSend: (PickedMediaPayload) -> Unit,
|
onSend: (PickedMediaPayload) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -2928,15 +3001,22 @@ private fun CircleVideoRecorderDialog(
|
|||||||
var activeRecording by remember { mutableStateOf<Recording?>(null) }
|
var activeRecording by remember { mutableStateOf<Recording?>(null) }
|
||||||
var activeFile by remember { mutableStateOf<File?>(null) }
|
var activeFile by remember { mutableStateOf<File?>(null) }
|
||||||
var isRecording by remember { mutableStateOf(false) }
|
var isRecording by remember { mutableStateOf(false) }
|
||||||
var isLocked by remember { mutableStateOf(false) }
|
|
||||||
var isProcessing by remember { mutableStateOf(false) }
|
var isProcessing by remember { mutableStateOf(false) }
|
||||||
var durationMs by remember { mutableStateOf(0L) }
|
var durationMs by remember { mutableStateOf(0L) }
|
||||||
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
||||||
var autoStartPending by remember { mutableStateOf(true) }
|
|
||||||
var recordedLensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) }
|
|
||||||
val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
|
val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
|
||||||
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
||||||
val progressActiveColor = Color.White
|
val progressActiveColor = Color.White
|
||||||
|
val recordingPulse = rememberInfiniteTransition(label = "circle_recording_pulse")
|
||||||
|
val pulseAlpha by recordingPulse.animateFloat(
|
||||||
|
initialValue = 0.45f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 850, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "circle_recording_dot",
|
||||||
|
)
|
||||||
|
|
||||||
SideEffect {
|
SideEffect {
|
||||||
previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f
|
previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f
|
||||||
@@ -3009,8 +3089,6 @@ private fun CircleVideoRecorderDialog(
|
|||||||
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
|
val file = createTempCaptureFile(context = context, prefix = "circle_", suffix = ".mp4")
|
||||||
activeFile = file
|
activeFile = file
|
||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
isLocked = false
|
|
||||||
recordedLensFacing = lensFacing
|
|
||||||
val output = FileOutputOptions.Builder(file).build()
|
val output = FileOutputOptions.Builder(file).build()
|
||||||
var pending = capture.output.prepareRecording(context, output)
|
var pending = capture.output.prepareRecording(context, output)
|
||||||
if (
|
if (
|
||||||
@@ -3044,7 +3122,6 @@ private fun CircleVideoRecorderDialog(
|
|||||||
transcodeCircleVideoToSquare(
|
transcodeCircleVideoToSquare(
|
||||||
context = context,
|
context = context,
|
||||||
inputFile = completedFile,
|
inputFile = completedFile,
|
||||||
mirrorHorizontally = recordedLensFacing == CameraSelector.LENS_FACING_FRONT,
|
|
||||||
)
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
val sourceFile = squaredFile ?: completedFile
|
val sourceFile = squaredFile ?: completedFile
|
||||||
@@ -3077,12 +3154,17 @@ private fun CircleVideoRecorderDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(videoCapture, autoStartPending) {
|
LaunchedEffect(videoCapture) {
|
||||||
if (autoStartPending && videoCapture != null && activeRecording == null) {
|
if (videoCapture != null && activeRecording == null && !isProcessing) {
|
||||||
autoStartPending = false
|
|
||||||
startRecording()
|
startRecording()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(pendingFinalizeAction, activeRecording) {
|
||||||
|
val action = pendingFinalizeAction ?: return@LaunchedEffect
|
||||||
|
if (activeRecording == null) return@LaunchedEffect
|
||||||
|
stopWith(action)
|
||||||
|
onFinalizeActionConsumed()
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -3183,7 +3265,13 @@ private fun CircleVideoRecorderDialog(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(8.dp)
|
.size(8.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (isRecording) Color.Red else Color.White.copy(alpha = 0.45f)),
|
.background(
|
||||||
|
if (isRecording) {
|
||||||
|
Color.Red.copy(alpha = pulseAlpha)
|
||||||
|
} else {
|
||||||
|
Color.White.copy(alpha = 0.45f)
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()),
|
text = if (isProcessing) "Processing…" else formatDuration(durationMs.toInt()),
|
||||||
@@ -3200,7 +3288,6 @@ private fun CircleVideoRecorderDialog(
|
|||||||
} else {
|
} else {
|
||||||
CameraSelector.LENS_FACING_BACK
|
CameraSelector.LENS_FACING_BACK
|
||||||
}
|
}
|
||||||
autoStartPending = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isRecording,
|
enabled = !isRecording,
|
||||||
@@ -3223,72 +3310,36 @@ private fun CircleVideoRecorderDialog(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
if (isLocked) {
|
||||||
text = if (isLocked) "Locked" else "← Отмена",
|
Row(
|
||||||
color = Color.White.copy(alpha = 0.8f),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Text(
|
) {
|
||||||
text = if (isLocked) "Tap to send" else "↑ Lock",
|
TextButton(
|
||||||
color = Color.White.copy(alpha = 0.72f),
|
onClick = { stopWith(CircleFinalizeAction.Cancel) },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
enabled = isRecording && !isProcessing,
|
||||||
)
|
) {
|
||||||
Surface(
|
Text(stringResource(id = R.string.common_cancel), color = Color.White)
|
||||||
shape = CircleShape,
|
}
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Text(
|
||||||
modifier = Modifier
|
text = stringResource(id = R.string.chat_voice_hint_locked),
|
||||||
.size(68.dp)
|
color = Color.White.copy(alpha = 0.78f),
|
||||||
.clip(CircleShape)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
.pointerInput(videoCapture, isRecording, isLocked, isProcessing) {
|
|
||||||
if (videoCapture == null || isProcessing) return@pointerInput
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown(requireUnconsumed = false)
|
|
||||||
if (!isRecording) {
|
|
||||||
startRecording()
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
return@awaitEachGesture
|
|
||||||
}
|
|
||||||
if (isLocked) {
|
|
||||||
waitForUpOrCancellation()
|
|
||||||
stopWith(CircleFinalizeAction.Send)
|
|
||||||
return@awaitEachGesture
|
|
||||||
}
|
|
||||||
var cancelled = false
|
|
||||||
var locked = false
|
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val change = event.changes.first()
|
|
||||||
val delta = change.position - down.position
|
|
||||||
if (!locked && delta.y < -120f) {
|
|
||||||
locked = true
|
|
||||||
isLocked = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (!locked && delta.x < -150f) {
|
|
||||||
cancelled = true
|
|
||||||
stopWith(CircleFinalizeAction.Cancel)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (!change.pressed) {
|
|
||||||
if (!cancelled && !locked) {
|
|
||||||
stopWith(CircleFinalizeAction.Send)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (change.positionChange() != Offset.Zero) {
|
|
||||||
change.consume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isRecording) Icons.AutoMirrored.Filled.Send else Icons.Filled.RadioButtonChecked,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { stopWith(CircleFinalizeAction.Send) },
|
||||||
|
enabled = isRecording && !isProcessing,
|
||||||
|
) {
|
||||||
|
Text(stringResource(id = R.string.common_send))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.chat_voice_hint_slide),
|
||||||
|
color = Color.White.copy(alpha = 0.82f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4284,6 +4335,16 @@ private fun VoiceRecordingStatusRow(
|
|||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val pulseTransition = rememberInfiniteTransition(label = "voice_recording_pulse")
|
||||||
|
val pulseAlpha by pulseTransition.animateFloat(
|
||||||
|
initialValue = 0.45f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 850, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "voice_recording_dot",
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -4295,14 +4356,25 @@ private fun VoiceRecordingStatusRow(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = stringResource(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
id = R.string.chat_voice_recording_duration,
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
formatDuration(durationMs.toInt()),
|
) {
|
||||||
),
|
Box(
|
||||||
style = MaterialTheme.typography.labelMedium,
|
modifier = Modifier
|
||||||
fontWeight = FontWeight.SemiBold,
|
.size(8.dp)
|
||||||
)
|
.clip(CircleShape)
|
||||||
|
.background(Color.Red.copy(alpha = pulseAlpha)),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.chat_voice_recording_duration,
|
||||||
|
formatDuration(durationMs.toInt()),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!hint.isNullOrBlank()) {
|
if (!hint.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = hint,
|
text = hint,
|
||||||
@@ -4711,42 +4783,32 @@ private fun UnifiedRecordButton(
|
|||||||
}
|
}
|
||||||
if (useCircleRecording) {
|
if (useCircleRecording) {
|
||||||
onStartCircle()
|
onStartCircle()
|
||||||
while (true) {
|
|
||||||
val event = awaitPointerEvent()
|
|
||||||
val change = event.changes.first()
|
|
||||||
if (!change.pressed) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (change.positionChange() != Offset.Zero) {
|
|
||||||
change.consume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
onStartVoice()
|
onStartVoice()
|
||||||
var cancelledBySlide = false
|
}
|
||||||
var lockedBySlide = false
|
var cancelledBySlide = false
|
||||||
while (true) {
|
var lockedBySlide = false
|
||||||
val event = awaitPointerEvent()
|
while (true) {
|
||||||
val change = event.changes.first()
|
val event = awaitPointerEvent()
|
||||||
val delta = change.position - down.position
|
val change = event.changes.first()
|
||||||
if (!lockedBySlide && delta.y < -120f) {
|
val delta = change.position - down.position
|
||||||
lockedBySlide = true
|
if (!lockedBySlide && delta.y < -120f) {
|
||||||
onLock()
|
lockedBySlide = true
|
||||||
}
|
onLock()
|
||||||
if (!lockedBySlide && delta.x < -150f) {
|
}
|
||||||
cancelledBySlide = true
|
if (!lockedBySlide && delta.x < -150f) {
|
||||||
onCancel()
|
cancelledBySlide = true
|
||||||
break
|
onCancel()
|
||||||
}
|
break
|
||||||
if (!change.pressed) {
|
}
|
||||||
if (!cancelledBySlide && !lockedBySlide) {
|
if (!change.pressed) {
|
||||||
onRelease()
|
if (!cancelledBySlide && !lockedBySlide) {
|
||||||
}
|
onRelease()
|
||||||
break
|
|
||||||
}
|
|
||||||
if (change.positionChange() != Offset.Zero) {
|
|
||||||
change.consume()
|
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (change.positionChange() != Offset.Zero) {
|
||||||
|
change.consume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4798,20 +4860,12 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? {
|
|||||||
private suspend fun transcodeCircleVideoToSquare(
|
private suspend fun transcodeCircleVideoToSquare(
|
||||||
context: Context,
|
context: Context,
|
||||||
inputFile: File,
|
inputFile: File,
|
||||||
mirrorHorizontally: Boolean,
|
|
||||||
): File = suspendCancellableCoroutine { continuation ->
|
): File = suspendCancellableCoroutine { continuation ->
|
||||||
val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4")
|
val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4")
|
||||||
val frameInfo = readVideoFrameInfo(inputFile)
|
val frameInfo = readVideoFrameInfo(inputFile)
|
||||||
val squareCrop = frameInfo?.toCenteredSquareCrop()
|
val squareCrop = frameInfo?.toCenteredSquareCrop()
|
||||||
val transformer = Transformer.Builder(context).build()
|
val transformer = Transformer.Builder(context).build()
|
||||||
val videoEffects = buildList {
|
val videoEffects = buildList {
|
||||||
if (mirrorHorizontally) {
|
|
||||||
add(
|
|
||||||
ScaleAndRotateTransformation.Builder()
|
|
||||||
.setScale(-1f, 1f)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (squareCrop != null) {
|
if (squareCrop != null) {
|
||||||
add(squareCrop)
|
add(squareCrop)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -808,12 +808,6 @@ class ChatViewModel @Inject constructor(
|
|||||||
it.copy(
|
it.copy(
|
||||||
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
|
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
|
||||||
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
|
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
|
||||||
errorMessage = listOf(membersResult, bansResult)
|
|
||||||
.filterIsInstance<AppResult.Error>()
|
|
||||||
.firstOrNull()
|
|
||||||
?.reason
|
|
||||||
?.toUiMessage()
|
|
||||||
?: it.errorMessage,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package ru.daemonlord.messenger.ui.chat
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.text.ClickableText
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -80,29 +84,35 @@ fun FormattedMessageText(
|
|||||||
baseColor = color,
|
baseColor = color,
|
||||||
revealedSpoilers = revealedSpoilers.toSet(),
|
revealedSpoilers = revealedSpoilers.toSet(),
|
||||||
)
|
)
|
||||||
ClickableText(
|
AnimatedContent(
|
||||||
text = annotated,
|
targetState = annotated,
|
||||||
style = style,
|
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||||
modifier = modifier,
|
label = "spoiler_reveal",
|
||||||
onClick = { offset ->
|
) { animatedAnnotated ->
|
||||||
val spoiler = annotated
|
ClickableText(
|
||||||
.getStringAnnotations(tag = "spoiler", start = offset, end = offset)
|
text = animatedAnnotated,
|
||||||
.firstOrNull()
|
style = style,
|
||||||
if (spoiler != null) {
|
modifier = modifier,
|
||||||
if (!revealedSpoilers.contains(spoiler.item)) {
|
onClick = { offset ->
|
||||||
revealedSpoilers.add(spoiler.item)
|
val spoiler = animatedAnnotated
|
||||||
|
.getStringAnnotations(tag = "spoiler", start = offset, end = offset)
|
||||||
|
.firstOrNull()
|
||||||
|
if (spoiler != null) {
|
||||||
|
if (!revealedSpoilers.contains(spoiler.item)) {
|
||||||
|
revealedSpoilers.add(spoiler.item)
|
||||||
|
}
|
||||||
|
return@ClickableText
|
||||||
}
|
}
|
||||||
return@ClickableText
|
animatedAnnotated
|
||||||
}
|
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
||||||
annotated
|
.firstOrNull()
|
||||||
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
?.item
|
||||||
.firstOrNull()
|
?.let { link ->
|
||||||
?.item
|
runCatching { uriHandler.openUri(link) }
|
||||||
?.let { link ->
|
}
|
||||||
runCatching { uriHandler.openUri(link) }
|
},
|
||||||
}
|
)
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildFormattedMessage(
|
private fun buildFormattedMessage(
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
<string name="common_send">Отправить</string>
|
<string name="common_send">Отправить</string>
|
||||||
<string name="common_save">Сохранить</string>
|
<string name="common_save">Сохранить</string>
|
||||||
<string name="common_unknown_user">Неизвестный пользователь</string>
|
<string name="common_unknown_user">Неизвестный пользователь</string>
|
||||||
|
<string name="notification_action_reply">Ответить</string>
|
||||||
|
<string name="notification_action_reply_hint">Введите ответ</string>
|
||||||
|
<string name="notification_action_mark_read">Прочитано</string>
|
||||||
<string name="profile_avatar_content_description">Аватар</string>
|
<string name="profile_avatar_content_description">Аватар</string>
|
||||||
<string name="profile_user_fallback">Пользователь</string>
|
<string name="profile_user_fallback">Пользователь</string>
|
||||||
<string name="profile_choose_photo">Выбрать фото</string>
|
<string name="profile_choose_photo">Выбрать фото</string>
|
||||||
|
|||||||
@@ -80,6 +80,9 @@
|
|||||||
<string name="common_send">Send</string>
|
<string name="common_send">Send</string>
|
||||||
<string name="common_save">Save</string>
|
<string name="common_save">Save</string>
|
||||||
<string name="common_unknown_user">Unknown user</string>
|
<string name="common_unknown_user">Unknown user</string>
|
||||||
|
<string name="notification_action_reply">Reply</string>
|
||||||
|
<string name="notification_action_reply_hint">Write a reply</string>
|
||||||
|
<string name="notification_action_mark_read">Mark as read</string>
|
||||||
<string name="profile_avatar_content_description">Avatar</string>
|
<string name="profile_avatar_content_description">Avatar</string>
|
||||||
<string name="profile_user_fallback">User</string>
|
<string name="profile_user_fallback">User</string>
|
||||||
<string name="profile_choose_photo">Choose photo</string>
|
<string name="profile_choose_photo">Choose photo</string>
|
||||||
|
|||||||
@@ -1116,36 +1116,49 @@ function renderMessageContent(
|
|||||||
if (mediaItems.length === 1) {
|
if (mediaItems.length === 1) {
|
||||||
const item = mediaItems[0];
|
const item = mediaItems[0];
|
||||||
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
||||||
|
const isInteractiveCircle = isCircleVideo && item.type === "video";
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<button
|
{isInteractiveCircle ? (
|
||||||
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
|
<div
|
||||||
onClick={() => {
|
className="relative block aspect-square w-56 overflow-hidden rounded-full bg-slate-950/30"
|
||||||
if (blockViewerOpen || isCircleVideo) {
|
onContextMenu={(event) => {
|
||||||
return;
|
event.stopPropagation();
|
||||||
}
|
opts.onAttachmentContextMenu(event, item.url);
|
||||||
opts.onOpenMedia(item.url, item.type);
|
}}
|
||||||
}}
|
>
|
||||||
onContextMenu={(event) => {
|
<CircleVideoInlinePlayer src={item.url} />
|
||||||
event.stopPropagation();
|
</div>
|
||||||
opts.onAttachmentContextMenu(event, item.url);
|
) : (
|
||||||
}}
|
<button
|
||||||
type="button"
|
className={`relative block overflow-hidden bg-slate-950/30 ${isCircleVideo ? "aspect-square w-56 rounded-full" : "rounded-xl"}`}
|
||||||
>
|
onClick={() => {
|
||||||
{item.type === "image" ? (
|
if (blockViewerOpen || isCircleVideo) {
|
||||||
<img
|
return;
|
||||||
alt="attachment"
|
}
|
||||||
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
|
opts.onOpenMedia(item.url, item.type);
|
||||||
draggable={false}
|
}}
|
||||||
src={item.url}
|
onContextMenu={(event) => {
|
||||||
/>
|
event.stopPropagation();
|
||||||
) : (
|
opts.onAttachmentContextMenu(event, item.url);
|
||||||
<>
|
}}
|
||||||
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
|
type="button"
|
||||||
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85">▶</span>
|
>
|
||||||
</>
|
{item.type === "image" ? (
|
||||||
)}
|
<img
|
||||||
</button>
|
alt="attachment"
|
||||||
|
className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl object-cover"}
|
||||||
|
draggable={false}
|
||||||
|
src={item.url}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<video className={isCircleVideo ? "h-full w-full object-cover" : "max-h-80 rounded-xl"} muted src={item.url} />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85">▶</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{captionText ? (
|
{captionText ? (
|
||||||
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
|
<p className="whitespace-pre-wrap break-words text-sm" dangerouslySetInnerHTML={{ __html: formatMessageHtml(captionText) }} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1500,6 +1513,71 @@ async function downloadFileFromUrl(url: string): Promise<void> {
|
|||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CircleVideoInlinePlayer({ src }: { src: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
video.currentTime = 0;
|
||||||
|
};
|
||||||
|
video.addEventListener("play", handlePlay);
|
||||||
|
video.addEventListener("pause", handlePause);
|
||||||
|
video.addEventListener("ended", handleEnded);
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener("play", handlePlay);
|
||||||
|
video.removeEventListener("pause", handlePause);
|
||||||
|
video.removeEventListener("ended", handleEnded);
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
async function togglePlayback() {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (video.paused || video.ended) {
|
||||||
|
try {
|
||||||
|
await video.play();
|
||||||
|
} catch {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
src={src}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/45 text-2xl text-white">
|
||||||
|
{isPlaying ? "❚❚" : "▶"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-label={isPlaying ? "Pause video message" : "Play video message"}
|
||||||
|
className="absolute inset-0"
|
||||||
|
onClick={() => void togglePlayback()}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
|
function AudioInlinePlayer({ src, title }: { src: string; title: string }) {
|
||||||
const track = useAudioPlayerStore((s) => s.track);
|
const track = useAudioPlayerStore((s) => s.track);
|
||||||
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
|
const isPlayingGlobal = useAudioPlayerStore((s) => s.isPlaying);
|
||||||
|
|||||||
Reference in New Issue
Block a user